From 5ceb258b3e9f346909336a75a12479bef786edb0 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 19 Jul 2023 12:34:24 -0700 Subject: [PATCH] Mute mics by default Fix bug when file ends in line with 1 more digit displayed than previous lines Remove stale UI elements from voice call development --- Cargo.lock | 6 ++- assets/settings/default.json | 5 ++ crates/call/Cargo.toml | 4 ++ crates/call/src/call.rs | 4 ++ crates/call/src/call_settings.rs | 27 +++++++++++ crates/call/src/room.rs | 41 +++++++++++++---- crates/collab_ui/src/collab_titlebar_item.rs | 8 ++-- crates/collab_ui/src/collab_ui.rs | 16 +------ crates/editor/src/element.rs | 2 +- .../Sources/LiveKitBridge/LiveKitBridge.swift | 46 +++++++++++++------ crates/live_kit_client/examples/test_app.rs | 2 +- crates/live_kit_client/src/prod.rs | 23 ++++++++-- crates/live_kit_client/src/test.rs | 14 +++++- 13 files changed, 147 insertions(+), 51 deletions(-) create mode 100644 crates/call/src/call_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 3ce94d08b9..91b165ca68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1052,6 +1052,10 @@ dependencies = [ "media", "postage", "project", + "schemars", + "serde", + "serde_derive", + "serde_json", "settings", "util", ] @@ -6470,7 +6474,7 @@ name = "search" version = "0.1.0" dependencies = [ "anyhow", - "bitflags 1.3.2", + "bitflags", "client", "collections", "editor", diff --git a/assets/settings/default.json b/assets/settings/default.json index e1f2d93270..d35049a84d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -66,6 +66,11 @@ // 3. Draw all invisible symbols: // "all" "show_whitespaces": "selection", + // Settings related to calls in Zed + "calls": { + // Join calls with the microphone muted by default + "mute_on_join": true + }, // Scrollbar related settings "scrollbar": { // When to show the scrollbar in the editor. diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index 61f3593247..eb448d8d8d 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -36,6 +36,10 @@ anyhow.workspace = true async-broadcast = "0.4" futures.workspace = true postage.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_derive.workspace = true [dev-dependencies] client = { path = "../client", features = ["test-support"] } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index cf6dd1799c..1ad7dbc1fc 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -1,9 +1,11 @@ pub mod participant; pub mod room; +pub mod call_settings; use std::sync::Arc; use anyhow::{anyhow, Result}; +use call_settings::CallSettings; use client::{proto, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore}; use collections::HashSet; use futures::{future::Shared, FutureExt}; @@ -19,6 +21,8 @@ pub use participant::ParticipantLocation; pub use room::Room; pub fn init(client: Arc, user_store: ModelHandle, cx: &mut AppContext) { + settings::register::(cx); + let active_call = cx.add_model(|cx| ActiveCall::new(client, user_store, cx)); cx.set_global(active_call); } diff --git a/crates/call/src/call_settings.rs b/crates/call/src/call_settings.rs new file mode 100644 index 0000000000..356ae3ef19 --- /dev/null +++ b/crates/call/src/call_settings.rs @@ -0,0 +1,27 @@ +use schemars::JsonSchema; +use serde_derive::{Serialize, Deserialize}; +use settings::Setting; + +#[derive(Deserialize, Debug)] +pub struct CallSettings { + pub mute_on_join: bool, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] +pub struct CallSettingsContent { + pub mute_on_join: Option, +} + +impl Setting for CallSettings { + const KEY: Option<&'static str> = Some("calls"); + + type FileContent = CallSettingsContent; + + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _: &gpui::AppContext, + ) -> anyhow::Result { + Self::load_via_json_merge(default_value, user_values) + } +} diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 87e6faf988..d57d4f711b 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -1,4 +1,5 @@ use crate::{ + call_settings::CallSettings, participant::{LocalParticipant, ParticipantLocation, RemoteParticipant, RemoteVideoTrack}, IncomingCall, }; @@ -19,7 +20,7 @@ use live_kit_client::{ }; use postage::stream::Stream; use project::Project; -use std::{future::Future, mem, pin::Pin, sync::Arc, time::Duration}; +use std::{future::Future, mem, pin::Pin, sync::Arc, time::Duration, panic::Location}; use util::{post_inc, ResultExt, TryFutureExt}; pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); @@ -153,8 +154,10 @@ impl Room { cx.spawn(|this, mut cx| async move { connect.await?; - this.update(&mut cx, |this, cx| this.share_microphone(cx)) - .await?; + if !cx.read(|cx| settings::get::(cx).mute_on_join) { + this.update(&mut cx, |this, cx| this.share_microphone(cx)) + .await?; + } anyhow::Ok(()) }) @@ -656,7 +659,7 @@ impl Room { peer_id, projects: participant.projects, location, - muted: false, + muted: true, speaking: false, video_tracks: Default::default(), audio_tracks: Default::default(), @@ -670,6 +673,10 @@ impl Room { live_kit.room.remote_video_tracks(&user.id.to_string()); let audio_tracks = live_kit.room.remote_audio_tracks(&user.id.to_string()); + let publications = live_kit + .room + .remote_audio_track_publications(&user.id.to_string()); + for track in video_tracks { this.remote_video_track_updated( RemoteVideoTrackUpdate::Subscribed(track), @@ -677,9 +684,15 @@ impl Room { ) .log_err(); } - for track in audio_tracks { + + for (track, publication) in + audio_tracks.iter().zip(publications.iter()) + { this.remote_audio_track_updated( - RemoteAudioTrackUpdate::Subscribed(track), + RemoteAudioTrackUpdate::Subscribed( + track.clone(), + publication.clone(), + ), cx, ) .log_err(); @@ -819,8 +832,8 @@ impl Room { cx.notify(); } RemoteAudioTrackUpdate::MuteChanged { track_id, muted } => { + let mut found = false; for participant in &mut self.remote_participants.values_mut() { - let mut found = false; for track in participant.audio_tracks.values() { if track.sid() == track_id { found = true; @@ -832,16 +845,20 @@ impl Room { break; } } + cx.notify(); } - RemoteAudioTrackUpdate::Subscribed(track) => { + RemoteAudioTrackUpdate::Subscribed(track, publication) => { let user_id = track.publisher_id().parse()?; let track_id = track.sid().to_string(); let participant = self .remote_participants .get_mut(&user_id) .ok_or_else(|| anyhow!("subscribed to track by unknown participant"))?; + participant.audio_tracks.insert(track_id.clone(), track); + participant.muted = publication.is_muted(); + cx.emit(Event::RemoteAudioTracksChanged { participant_id: participant.peer_id, }); @@ -1053,7 +1070,7 @@ impl Room { self.live_kit .as_ref() .and_then(|live_kit| match &live_kit.microphone_track { - LocalTrack::None => None, + LocalTrack::None => Some(true), LocalTrack::Pending { muted, .. } => Some(*muted), LocalTrack::Published { muted, .. } => Some(*muted), }) @@ -1070,7 +1087,9 @@ impl Room { self.live_kit.as_ref().map(|live_kit| live_kit.deafened) } + #[track_caller] pub fn share_microphone(&mut self, cx: &mut ModelContext) -> Task> { + dbg!(Location::caller()); if self.status.is_offline() { return Task::ready(Err(anyhow!("room is offline"))); } else if self.is_sharing_mic() { @@ -1244,6 +1263,10 @@ impl Room { pub fn toggle_mute(&mut self, cx: &mut ModelContext) -> Result>> { let should_mute = !self.is_muted(); if let Some(live_kit) = self.live_kit.as_mut() { + if matches!(live_kit.microphone_track, LocalTrack::None) { + return Ok(self.share_microphone(cx)); + } + let (ret_task, old_muted) = live_kit.set_mute(should_mute, cx)?; live_kit.muted_by_user = should_mute; diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 6cfc9d8e30..ce8d10d655 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -652,10 +652,10 @@ impl CollabTitlebarItem { let is_muted = room.read(cx).is_muted(); if is_muted { icon = "icons/radix/mic-mute.svg"; - tooltip = "Unmute microphone\nRight click for options"; + tooltip = "Unmute microphone"; } else { icon = "icons/radix/mic.svg"; - tooltip = "Mute microphone\nRight click for options"; + tooltip = "Mute microphone"; } let titlebar = &theme.titlebar; @@ -705,10 +705,10 @@ impl CollabTitlebarItem { let is_deafened = room.read(cx).is_deafened().unwrap_or(false); if is_deafened { icon = "icons/radix/speaker-off.svg"; - tooltip = "Unmute speakers\nRight click for options"; + tooltip = "Unmute speakers"; } else { icon = "icons/radix/speaker-loud.svg"; - tooltip = "Mute speakers\nRight click for options"; + tooltip = "Mute speakers"; } let titlebar = &theme.titlebar; diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 7608fdbfee..dbdeb45573 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -18,13 +18,7 @@ use workspace::AppState; actions!( collab, - [ - ToggleScreenSharing, - ToggleMute, - ToggleDeafen, - LeaveCall, - ShareMicrophone - ] + [ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall] ); pub fn init(app_state: &Arc, cx: &mut AppContext) { @@ -40,7 +34,6 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { cx.add_global_action(toggle_screen_sharing); cx.add_global_action(toggle_mute); cx.add_global_action(toggle_deafen); - cx.add_global_action(share_microphone); } pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) { @@ -85,10 +78,3 @@ pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) { .log_err(); } } - -pub fn share_microphone(_: &ShareMicrophone, cx: &mut AppContext) { - if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { - room.update(cx, Room::share_microphone) - .detach_and_log_err(cx) - } -} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4f4aa7477d..4962b08db2 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1311,7 +1311,7 @@ impl EditorElement { } fn max_line_number_width(&self, snapshot: &EditorSnapshot, cx: &ViewContext) -> f32 { - let digit_count = (snapshot.max_buffer_row() as f32).log10().floor() as usize + 1; + let digit_count = (snapshot.max_buffer_row() as f32 + 1.).log10().floor() as usize + 1; let style = &self.style; cx.text_layout_cache() diff --git a/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift b/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift index 40d3641db2..5f22acf581 100644 --- a/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift +++ b/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift @@ -6,7 +6,7 @@ import ScreenCaptureKit class LKRoomDelegate: RoomDelegate { var data: UnsafeRawPointer var onDidDisconnect: @convention(c) (UnsafeRawPointer) -> Void - var onDidSubscribeToRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void + var onDidSubscribeToRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer, UnsafeRawPointer) -> Void var onDidUnsubscribeFromRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void var onMuteChangedFromRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, Bool) -> Void var onActiveSpeakersChanged: @convention(c) (UnsafeRawPointer, CFArray) -> Void @@ -16,7 +16,7 @@ class LKRoomDelegate: RoomDelegate { init( data: UnsafeRawPointer, onDidDisconnect: @escaping @convention(c) (UnsafeRawPointer) -> Void, - onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, + onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer, UnsafeRawPointer) -> Void, onDidUnsubscribeFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void, onMuteChangedFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, Bool) -> Void, onActiveSpeakersChanged: @convention(c) (UnsafeRawPointer, CFArray) -> Void, @@ -43,7 +43,7 @@ class LKRoomDelegate: RoomDelegate { if track.kind == .video { self.onDidSubscribeToRemoteVideoTrack(self.data, participant.identity as CFString, track.sid! as CFString, Unmanaged.passUnretained(track).toOpaque()) } else if track.kind == .audio { - self.onDidSubscribeToRemoteAudioTrack(self.data, participant.identity as CFString, track.sid! as CFString, Unmanaged.passUnretained(track).toOpaque()) + self.onDidSubscribeToRemoteAudioTrack(self.data, participant.identity as CFString, track.sid! as CFString, Unmanaged.passUnretained(track).toOpaque(), Unmanaged.passUnretained(publication).toOpaque()) } } @@ -52,12 +52,12 @@ class LKRoomDelegate: RoomDelegate { self.onMuteChangedFromRemoteAudioTrack(self.data, publication.sid as CFString, muted) } } - + func room(_ room: Room, didUpdate speakers: [Participant]) { guard let speaker_ids = speakers.compactMap({ $0.identity as CFString }) as CFArray? else { return } self.onActiveSpeakersChanged(self.data, speaker_ids) } - + func room(_ room: Room, participant: RemoteParticipant, didUnsubscribe publication: RemoteTrackPublication, track: Track) { if track.kind == .video { self.onDidUnsubscribeFromRemoteVideoTrack(self.data, participant.identity as CFString, track.sid! as CFString) @@ -104,7 +104,7 @@ class LKVideoRenderer: NSObject, VideoRenderer { public func LKRoomDelegateCreate( data: UnsafeRawPointer, onDidDisconnect: @escaping @convention(c) (UnsafeRawPointer) -> Void, - onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, + onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer, UnsafeRawPointer) -> Void, onDidUnsubscribeFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void, onMuteChangedFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, Bool) -> Void, onActiveSpeakerChanged: @escaping @convention(c) (UnsafeRawPointer, CFArray) -> Void, @@ -180,39 +180,39 @@ public func LKRoomUnpublishTrack(room: UnsafeRawPointer, publication: UnsafeRawP @_cdecl("LKRoomAudioTracksForRemoteParticipant") public func LKRoomAudioTracksForRemoteParticipant(room: UnsafeRawPointer, participantId: CFString) -> CFArray? { let room = Unmanaged.fromOpaque(room).takeUnretainedValue() - + for (_, participant) in room.remoteParticipants { if participant.identity == participantId as String { return participant.audioTracks.compactMap { $0.track as? RemoteAudioTrack } as CFArray? } } - + return nil; } @_cdecl("LKRoomAudioTrackPublicationsForRemoteParticipant") public func LKRoomAudioTrackPublicationsForRemoteParticipant(room: UnsafeRawPointer, participantId: CFString) -> CFArray? { let room = Unmanaged.fromOpaque(room).takeUnretainedValue() - + for (_, participant) in room.remoteParticipants { if participant.identity == participantId as String { return participant.audioTracks.compactMap { $0 as? RemoteTrackPublication } as CFArray? } } - + return nil; } @_cdecl("LKRoomVideoTracksForRemoteParticipant") public func LKRoomVideoTracksForRemoteParticipant(room: UnsafeRawPointer, participantId: CFString) -> CFArray? { let room = Unmanaged.fromOpaque(room).takeUnretainedValue() - + for (_, participant) in room.remoteParticipants { if participant.identity == participantId as String { return participant.videoTracks.compactMap { $0.track as? RemoteVideoTrack } as CFArray? } } - + return nil; } @@ -222,7 +222,7 @@ public func LKLocalAudioTrackCreateTrack() -> UnsafeMutableRawPointer { echoCancellation: true, noiseSuppression: true )) - + return Unmanaged.passRetained(track).toOpaque() } @@ -276,7 +276,7 @@ public func LKLocalTrackPublicationSetMute( callback_data: UnsafeRawPointer ) { let publication = Unmanaged.fromOpaque(publication).takeUnretainedValue() - + if muted { publication.mute().then { on_complete(callback_data, nil) @@ -307,3 +307,21 @@ public func LKRemoteTrackPublicationSetEnabled( on_complete(callback_data, error.localizedDescription as CFString) } } + +@_cdecl("LKRemoteTrackPublicationIsMuted") +public func LKRemoteTrackPublicationIsMuted( + publication: UnsafeRawPointer +) -> Bool { + let publication = Unmanaged.fromOpaque(publication).takeUnretainedValue() + + return publication.muted +} + +@_cdecl("LKRemoteTrackPublicationGetSid") +public func LKRemoteTrackPublicationGetSid( + publication: UnsafeRawPointer +) -> CFString { + let publication = Unmanaged.fromOpaque(publication).takeUnretainedValue() + + return publication.sid as CFString +} diff --git a/crates/live_kit_client/examples/test_app.rs b/crates/live_kit_client/examples/test_app.rs index f5f6d0e46f..f2169d7f30 100644 --- a/crates/live_kit_client/examples/test_app.rs +++ b/crates/live_kit_client/examples/test_app.rs @@ -63,7 +63,7 @@ fn main() { let audio_track = LocalAudioTrack::create(); let audio_track_publication = room_a.publish_audio_track(&audio_track).await.unwrap(); - if let RemoteAudioTrackUpdate::Subscribed(track) = + if let RemoteAudioTrackUpdate::Subscribed(track, _) = audio_track_updates.next().await.unwrap() { let remote_tracks = room_b.remote_audio_tracks("test-participant-1"); diff --git a/crates/live_kit_client/src/prod.rs b/crates/live_kit_client/src/prod.rs index 6daa0601ca..2b5148e4a3 100644 --- a/crates/live_kit_client/src/prod.rs +++ b/crates/live_kit_client/src/prod.rs @@ -26,6 +26,7 @@ extern "C" { publisher_id: CFStringRef, track_id: CFStringRef, remote_track: *const c_void, + remote_publication: *const c_void, ), on_did_unsubscribe_from_remote_audio_track: extern "C" fn( callback_data: *mut c_void, @@ -125,6 +126,9 @@ extern "C" { on_complete: extern "C" fn(callback_data: *mut c_void, error: CFStringRef), callback_data: *mut c_void, ); + + fn LKRemoteTrackPublicationIsMuted(publication: *const c_void) -> bool; + fn LKRemoteTrackPublicationGetSid(publication: *const c_void) -> CFStringRef; } pub type Sid = String; @@ -372,10 +376,11 @@ impl Room { rx } - fn did_subscribe_to_remote_audio_track(&self, track: RemoteAudioTrack) { + fn did_subscribe_to_remote_audio_track(&self, track: RemoteAudioTrack, publication: RemoteTrackPublication) { let track = Arc::new(track); + let publication = Arc::new(publication); self.remote_audio_track_subscribers.lock().retain(|tx| { - tx.unbounded_send(RemoteAudioTrackUpdate::Subscribed(track.clone())) + tx.unbounded_send(RemoteAudioTrackUpdate::Subscribed(track.clone(), publication.clone())) .is_ok() }); } @@ -501,13 +506,15 @@ impl RoomDelegate { publisher_id: CFStringRef, track_id: CFStringRef, track: *const c_void, + publication: *const c_void, ) { let room = unsafe { Weak::from_raw(room as *mut Room) }; let publisher_id = unsafe { CFString::wrap_under_get_rule(publisher_id).to_string() }; let track_id = unsafe { CFString::wrap_under_get_rule(track_id).to_string() }; let track = RemoteAudioTrack::new(track, track_id, publisher_id); + let publication = RemoteTrackPublication::new(publication); if let Some(room) = room.upgrade() { - room.did_subscribe_to_remote_audio_track(track); + room.did_subscribe_to_remote_audio_track(track, publication); } let _ = Weak::into_raw(room); } @@ -682,6 +689,14 @@ impl RemoteTrackPublication { Self(native_track_publication) } + pub fn sid(&self) -> String { + unsafe { CFString::wrap_under_get_rule(LKRemoteTrackPublicationGetSid(self.0)).to_string() } + } + + pub fn is_muted(&self) -> bool { + unsafe { LKRemoteTrackPublicationIsMuted(self.0) } + } + pub fn set_enabled(&self, enabled: bool) -> impl Future> { let (tx, rx) = futures::channel::oneshot::channel(); @@ -832,7 +847,7 @@ pub enum RemoteVideoTrackUpdate { pub enum RemoteAudioTrackUpdate { ActiveSpeakersChanged { speakers: Vec }, MuteChanged { track_id: Sid, muted: bool }, - Subscribed(Arc), + Subscribed(Arc, Arc), Unsubscribed { publisher_id: Sid, track_id: Sid }, } diff --git a/crates/live_kit_client/src/test.rs b/crates/live_kit_client/src/test.rs index ada864fc44..3b91e0ef89 100644 --- a/crates/live_kit_client/src/test.rs +++ b/crates/live_kit_client/src/test.rs @@ -216,6 +216,8 @@ impl TestServer { publisher_id: identity.clone(), }); + let publication = Arc::new(RemoteTrackPublication); + room.audio_tracks.push(track.clone()); for (id, client_room) in &room.client_rooms { @@ -225,7 +227,7 @@ impl TestServer { .lock() .audio_track_updates .0 - .try_broadcast(RemoteAudioTrackUpdate::Subscribed(track.clone())) + .try_broadcast(RemoteAudioTrackUpdate::Subscribed(track.clone(), publication.clone())) .unwrap(); } } @@ -501,6 +503,14 @@ impl RemoteTrackPublication { pub fn set_enabled(&self, _enabled: bool) -> impl Future> { async { Ok(()) } } + + pub fn is_muted(&self) -> bool { + false + } + + pub fn sid(&self) -> String { + "".to_string() + } } #[derive(Clone)] @@ -579,7 +589,7 @@ pub enum RemoteVideoTrackUpdate { pub enum RemoteAudioTrackUpdate { ActiveSpeakersChanged { speakers: Vec }, MuteChanged { track_id: Sid, muted: bool }, - Subscribed(Arc), + Subscribed(Arc, Arc), Unsubscribed { publisher_id: Sid, track_id: Sid }, }