Skip to content

Commit 2cb0a9d

Browse files
feat(cast): proxy volume control to Chromecast receiver when casting
Agent-Logs-Url: https://github.com/huynhsontung/Screenbox/sessions/a7e8afc9-0c2e-45f0-ab40-ae36e09963df Co-authored-by: huynhsontung <31434093+huynhsontung@users.noreply.github.com>
1 parent 98f5e48 commit 2cb0a9d

4 files changed

Lines changed: 156 additions & 1 deletion

File tree

Screenbox.Core/Contexts/CastContext.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Screenbox.Core.Helpers;
66
using Screenbox.Core.Models;
77
using Sharpcaster;
8+
using Sharpcaster.Models.ChromecastStatus;
89
using Sharpcaster.Models.Media;
910
using Windows.System;
1011

@@ -56,6 +57,21 @@ public sealed partial class CastContext : ObservableObject
5657
[ObservableProperty]
5758
private bool _castIsBuffering;
5859

60+
/// <summary>
61+
/// The current receiver volume level reported by the Chromecast device, as a value
62+
/// between 0.0 (silent) and 1.0 (full volume).
63+
/// Updated from <see cref="Sharpcaster.Channels.ReceiverChannel.ReceiverStatusChanged"/>.
64+
/// </summary>
65+
[ObservableProperty]
66+
private double _castVolume;
67+
68+
/// <summary>
69+
/// <c>true</c> when the Chromecast device's receiver is muted.
70+
/// Updated from <see cref="Sharpcaster.Channels.ReceiverChannel.ReceiverStatusChanged"/>.
71+
/// </summary>
72+
[ObservableProperty]
73+
private bool _castIsMuted;
74+
5975
/// <summary>
6076
/// Raised when the Chromecast device transitions to the <c>IDLE</c> state with reason
6177
/// <c>FINISHED</c>, <c>ERROR</c>, or <c>CANCELLED</c>, indicating that playback ended
@@ -83,11 +99,13 @@ partial void OnClientChanged(ChromecastClient? oldValue, ChromecastClient? newVa
8399
if (oldValue is not null)
84100
{
85101
oldValue.MediaChannel.StatusChanged -= OnMediaStatusChanged;
102+
oldValue.ReceiverChannel.ReceiverStatusChanged -= OnReceiverStatusChanged;
86103
}
87104

88105
if (newValue is not null)
89106
{
90107
newValue.MediaChannel.StatusChanged += OnMediaStatusChanged;
108+
newValue.ReceiverChannel.ReceiverStatusChanged += OnReceiverStatusChanged;
91109
}
92110
}
93111

@@ -120,4 +138,24 @@ private void OnMediaStatusChanged(object sender, MediaStatus status)
120138
}
121139
});
122140
}
141+
142+
/// <summary>
143+
/// Handles receiver status updates pushed by the active Chromecast device.
144+
/// Marshals volume and mute state updates onto the UI thread.
145+
/// </summary>
146+
private void OnReceiverStatusChanged(object sender, ChromecastStatus status)
147+
{
148+
_dispatcherQueue.TryEnqueue(() =>
149+
{
150+
if (status.Volume?.Level is { } level)
151+
{
152+
CastVolume = level;
153+
}
154+
155+
if (status.Volume?.Muted is { } muted)
156+
{
157+
CastIsMuted = muted;
158+
}
159+
});
160+
}
123161
}

Screenbox.Core/Services/CastService.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,32 @@ public async Task SeekAsync(ChromecastClient client, TimeSpan position)
165165
}
166166
}
167167

168+
/// <inheritdoc/>
169+
public async Task SetVolumeAsync(ChromecastClient client, double level)
170+
{
171+
try
172+
{
173+
await client.ReceiverChannel.SetVolume(Math.Clamp(level, 0.0, 1.0));
174+
}
175+
catch (Exception)
176+
{
177+
// Ignore — the device may be disconnecting or the session may have ended.
178+
}
179+
}
180+
181+
/// <inheritdoc/>
182+
public async Task SetMuteAsync(ChromecastClient client, bool muted)
183+
{
184+
try
185+
{
186+
await client.ReceiverChannel.SetMute(muted);
187+
}
188+
catch (Exception)
189+
{
190+
// Ignore — the device may be disconnecting or the session may have ended.
191+
}
192+
}
193+
168194
// -------------------------------------------------------------------------
169195
// Helpers
170196
// -------------------------------------------------------------------------

Screenbox.Core/Services/ICastService.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,19 @@ public interface ICastService
5757
/// <param name="client">The active cast client.</param>
5858
/// <param name="position">The target playback position.</param>
5959
Task SeekAsync(ChromecastClient client, TimeSpan position);
60+
61+
/// <summary>
62+
/// Sets the receiver volume level on the Chromecast device.
63+
/// </summary>
64+
/// <param name="client">The active cast client.</param>
65+
/// <param name="level">Volume level between 0.0 (silent) and 1.0 (full).</param>
66+
Task SetVolumeAsync(ChromecastClient client, double level);
67+
68+
/// <summary>
69+
/// Sets the receiver mute state on the Chromecast device.
70+
/// </summary>
71+
/// <param name="client">The active cast client.</param>
72+
/// <param name="muted">Whether the receiver should be muted.</param>
73+
Task SetMuteAsync(ChromecastClient client, bool muted);
6074
}
6175

Screenbox.Core/ViewModels/VolumeViewModel.cs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#nullable enable
22

33
using System;
4+
using System.ComponentModel;
45
using CommunityToolkit.Mvvm.ComponentModel;
56
using CommunityToolkit.Mvvm.Messaging;
67
using CommunityToolkit.Mvvm.Messaging.Messages;
@@ -26,11 +27,20 @@ public sealed partial class VolumeViewModel : ObservableRecipient,
2627
private readonly DispatcherQueue _dispatcherQueue;
2728
private readonly ISettingsService _settingsService;
2829
private readonly PlayerContext _playerContext;
30+
private readonly CastContext _castContext;
31+
private readonly ICastService _castService;
2932

30-
public VolumeViewModel(ISettingsService settingsService, PlayerContext playerContext)
33+
// Guards against a re-entrant SetVolumeAsync/SetMuteAsync call when Volume or IsMute
34+
// is updated programmatically from a Chromecast status event.
35+
private bool _updatingFromCast;
36+
37+
public VolumeViewModel(ISettingsService settingsService, PlayerContext playerContext,
38+
CastContext castContext, ICastService castService)
3139
{
3240
_settingsService = settingsService;
3341
_playerContext = playerContext;
42+
_castContext = castContext;
43+
_castService = castService;
3444
_volume = settingsService.PersistentVolume;
3545
_maxVolume = settingsService.MaxVolume;
3646
_isMute = _volume == 0;
@@ -42,6 +52,8 @@ public VolumeViewModel(ISettingsService settingsService, PlayerContext playerCon
4252
MediaPlayer.IsMutedChanged += OnIsMutedChanged;
4353
}
4454

55+
_castContext.PropertyChanged += OnCastContextPropertyChanged;
56+
4557
// View model doesn't receive any messages
4658
IsActive = true;
4759
}
@@ -77,6 +89,17 @@ public void Receive(ChangeVolumeRequestMessage message)
7789

7890
partial void OnVolumeChanged(int value)
7991
{
92+
// While casting, proxy the volume change to the Chromecast receiver.
93+
if (_castContext.IsCasting && _castContext.Client is { } castClient)
94+
{
95+
if (!_updatingFromCast)
96+
{
97+
_ = _castService.SetVolumeAsync(castClient, value / 100.0);
98+
}
99+
100+
return;
101+
}
102+
80103
if (MediaPlayer == null) return;
81104
double newValue = value / 100d;
82105
// bool stayMute = IsMute && newValue - MediaPlayer.Volume < 0.005;
@@ -87,6 +110,17 @@ partial void OnVolumeChanged(int value)
87110

88111
partial void OnIsMuteChanged(bool value)
89112
{
113+
// While casting, proxy the mute change to the Chromecast receiver.
114+
if (_castContext.IsCasting && _castContext.Client is { } castClient)
115+
{
116+
if (!_updatingFromCast)
117+
{
118+
_ = _castService.SetMuteAsync(castClient, value);
119+
}
120+
121+
return;
122+
}
123+
90124
if (MediaPlayer == null) return;
91125
MediaPlayer.IsMuted = value;
92126
}
@@ -108,6 +142,49 @@ private void OnIsMutedChanged(IMediaPlayer sender, object? args)
108142
}
109143
}
110144

145+
/// <summary>
146+
/// Handles changes to the <see cref="CastContext"/> so that the volume control reflects
147+
/// the Chromecast device's receiver volume while casting and reverts to the local player
148+
/// once the session ends.
149+
/// </summary>
150+
private void OnCastContextPropertyChanged(object sender, PropertyChangedEventArgs e)
151+
{
152+
switch (e.PropertyName)
153+
{
154+
case nameof(CastContext.IsCasting):
155+
_updatingFromCast = true;
156+
if (_castContext.IsCasting)
157+
{
158+
// Casting just started — seed Volume and IsMute from the receiver's
159+
// last known state. The receiver will push a status update shortly
160+
// with the authoritative values.
161+
Volume = (int)Math.Round(_castContext.CastVolume * 100);
162+
IsMute = _castContext.CastIsMuted;
163+
}
164+
else
165+
{
166+
// Casting ended — restore volume from the local player / persisted setting.
167+
Volume = _settingsService.PersistentVolume;
168+
IsMute = Volume == 0;
169+
}
170+
171+
_updatingFromCast = false;
172+
break;
173+
174+
case nameof(CastContext.CastVolume) when _castContext.IsCasting:
175+
_updatingFromCast = true;
176+
Volume = (int)Math.Round(_castContext.CastVolume * 100);
177+
_updatingFromCast = false;
178+
break;
179+
180+
case nameof(CastContext.CastIsMuted) when _castContext.IsCasting:
181+
_updatingFromCast = true;
182+
IsMute = _castContext.CastIsMuted;
183+
_updatingFromCast = false;
184+
break;
185+
}
186+
}
187+
111188
/// <summary>
112189
/// Sets the volume to a specified value or adjusts it by a given amount.
113190
/// </summary>

0 commit comments

Comments
 (0)