diff --git a/README.md b/README.md index 4264947..adaa61c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ A terminal-based music player (TUI) for streaming services, currently supporting ## 🌟 Features - **TUI Interface**: Built with [Terminal.Gui](https://github.com/gui-cs/Terminal.Gui) for a rich console experience. -- **Cross-Platform Audio**: Powered by [SoundFlow](https://github.com/LSXPrime/SoundFlow). +- **Cross-Platform Audio**: Local playback powered by [SoundFlow](https://github.com/LSXPrime/SoundFlow). +- **Google Cast Support**: Stream audio directly to Chromecast devices, Nest smart speakers, and Cast-enabled receivers on your local network using on-demand mDNS discovery and a built-in local streaming proxy. - **YouTube Music Integration**: Powered by [YouTubeMusicAPI](https://github.com/IcySnex/YouTubeMusicAPI). Search, stream, and manage your library. - **Subsonic API Integration**: Streaming client for any Subsonic-compatible server. For instance, [Navidrome](https://www.navidrome.org/), [Airsonic Advanced](https://github.com/airsonic-advanced/airsonic-advanced), [Gonic](https://github.com/sentriz/gonic), or [Madsonic](https://www.madsonic.org/). - **Visuals**: Displays album art using Sixel graphics (requires a compatible terminal) and a beautiful real-time audio frequency spectrum visualizer. @@ -57,7 +58,7 @@ While most basic functionality is available and SMoC is totally usable, there's - [x] Start Over - [ ] Repeat Track - [x] Volume - - [ ] Playback Device Selection + - [x] Playback Device Selection (Local & Google Cast) - [ ] Gapless Playback - [ ] Queue - [x] Add to End of Queue @@ -165,8 +166,21 @@ SMoC operates with a Vim-style command bar. Press `:` to enter command mode. | `:pq` | View the **Playback Queue** | | `:np` | View **Now Playing** screen | | `:np-vis` | Toggle real-time frequency visualizer in Now Playing | -| `:v/<0-100>` | Set volume (e.g., `:v/80`) | -| `:q` | **Quit** application | +| `:output` | List available audio output targets (local & Cast devices) | +| `:output/` | Switch output to a Cast device (name/IP), or `:output/local` | +| `:output/refresh` | Perform an active on-demand mDNS scan for Cast devices | +| `:v/<0-100>` | Set volume (e.g., `:v/80`) | +| `:q` | **Quit** application | + +### Audio Outputs & Casting + +SMoC supports switching audio output on the fly without interrupting your listening session: + +* **List outputs:** Enter `:output` to view all available local and networked Cast devices. +* **Cast to a device:** Type `:output/Living Room` (or `:output/192.168.1.50`). Playback immediately transfers to the Chromecast device and resumes at the current track and position. +* **Return to local audio:** Enter `:output/local` to switch back to local sound card playback. +* **Rescan network:** If a device was recently powered on, enter `:output/refresh` to force an active mDNS probe. +* **Tab-completion:** Typing `:output/` and pressing `Tab` automatically completes matching device names, `local`, and `refresh`. ### Playback Controls diff --git a/smoc.Tests/Services/Audio/Cast/CastAudioServiceTest.cs b/smoc.Tests/Services/Audio/Cast/CastAudioServiceTest.cs new file mode 100644 index 0000000..468e84e --- /dev/null +++ b/smoc.Tests/Services/Audio/Cast/CastAudioServiceTest.cs @@ -0,0 +1,163 @@ +using System.Collections.Generic; +using Smoc.Services; +using Sharpcaster.Models.Media; +using Moq; +using Sharpcaster.Models; +using Smoc.Services.Audio.Cast; +using Smoc.Services.Cast; +using Smoc.Streaming; +using smoc.Tests.TestInfra; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace smoc.Tests.Services.Audio.Cast; + +public class CastAudioServiceTest { + private readonly Mock _mockProxyService; + private readonly Mock _mockClient; + private readonly ChromecastReceiver _device; + + public CastAudioServiceTest() { + _mockProxyService = new Mock(); + _mockClient = new Mock(); + _device = new ChromecastReceiver { + DeviceUri = new Uri("http://192.168.1.100:8008"), + Name = "Test Cast Device" + }; + } + + [Fact] + public void MakePlaybackService_ReturnsCastPlaybackService() { + var sut = new CastAudioService(_device, _mockProxyService.Object, _mockClient.Object); + var song = EntityTestFactory.GenerateSong(); + var stream = new MemoryStream(); + + var playbackService = sut.MakePlaybackService(song, stream, "mp3", TestContext.Current.CancellationToken); + + Assert.NotNull(playbackService); + Assert.IsType(playbackService); + Assert.Equal(song, playbackService.Song); + } + + [Fact] + public void MakePlaybackService_StartsProxyWithCorrectContentType() { + var sut = new CastAudioService(_device, _mockProxyService.Object, _mockClient.Object); + var song = EntityTestFactory.GenerateSong(); + var stream = new MemoryStream(); + + _mockProxyService.Setup(p => p.StartProxy(stream, "audio/mpeg")).Returns("http://proxy/stream"); + + sut.MakePlaybackService(song, stream, "mp3", TestContext.Current.CancellationToken); + + _mockProxyService.Verify(p => p.StartProxy(stream, "audio/mpeg"), Times.Once); + } + + [Fact] + public async Task ConnectAsync_CallsClientEnsureConnectedAndLaunched() { + var sut = new CastAudioService(_device, _mockProxyService.Object, _mockClient.Object); + + await sut.ConnectAsync(TestContext.Current.CancellationToken); + + _mockClient.Verify(c => c.EnsureConnectedAndLaunchedAsync(_device, "CC1AD845", It.IsAny()), Times.Once); + } + + [Fact] + public void Volume_GetAndSet_UpdatesVolumeAndInvokesClient() { + var sut = new CastAudioService(_device, _mockProxyService.Object, _mockClient.Object); + + sut.Volume = 0.8f; + + Assert.Equal(0.8f, sut.Volume); + _mockClient.Verify(c => c.SetVolumeAsync(0.8f, It.IsAny()), Times.Once); + } + + [Fact] + public void Volume_ClampsBetweenZeroAndOne() { + var sut = new CastAudioService(_device, _mockProxyService.Object, _mockClient.Object); + + sut.Volume = 1.8f; + Assert.Equal(1.0f, sut.Volume); + + sut.Volume = -0.4f; + Assert.Equal(0.0f, sut.Volume); + } + + [Theory] + [InlineData("flac", "audio/flac")] + [InlineData("m4a", "audio/mp4")] + [InlineData("aac", "audio/aac")] + [InlineData("ogg", "audio/ogg")] + [InlineData("wav", "audio/wav")] + [InlineData("unknown", "audio/mpeg")] + public void MakePlaybackService_MapsCodecsToCorrectMimeType(string codec, string expectedMimeType) { + var sut = new CastAudioService(_device, _mockProxyService.Object, _mockClient.Object); + var song = EntityTestFactory.GenerateSong(); + var stream = new MemoryStream(); + + _mockProxyService.Setup(p => p.StartProxy(stream, expectedMimeType)).Returns("http://proxy/stream"); + + sut.MakePlaybackService(song, stream, codec, TestContext.Current.CancellationToken); + + _mockProxyService.Verify(p => p.StartProxy(stream, expectedMimeType), Times.Once); + } + + [Fact] + public void Dispose_DisconnectsAndDisposesClient() { + var sut = new CastAudioService(_device, _mockProxyService.Object, _mockClient.Object); + + sut.Dispose(); + + _mockClient.Verify(c => c.DisconnectAsync(It.IsAny()), Times.Once); + _mockClient.Verify(c => c.Dispose(), Times.Once); + } + + [Fact] + public async Task OnSongEnded_AutomaticallyTransitionsToNextPreloadedTrack() { + var loadedMediaTitles = new List(); + _mockClient.Setup(c => c.LoadAsync(It.IsAny(), It.IsAny())).Returns((Media m, CancellationToken ct) => { + loadedMediaTitles.Add(m.Metadata?.Title ?? string.Empty); + return Task.CompletedTask; + }); + + var sut = new CastAudioService(_device, _mockProxyService.Object, _mockClient.Object); + var mockStreaming = new Mock(); + var song1 = EntityTestFactory.GenerateSong(id: "1", postfix: "1"); + var song2 = EntityTestFactory.GenerateSong(id: "2", postfix: "2"); + + mockStreaming.Setup(c => c.GetSongStreamAsync(song1.Id, It.IsAny())) + .ReturnsAsync(new SongStream(song1.Id, "mp3", new MemoryStream())); + mockStreaming.Setup(c => c.GetSongStreamAsync(song2.Id, It.IsAny())) + .ReturnsAsync(new SongStream(song2.Id, "mp3", new MemoryStream())); + + _mockProxyService.Setup(p => p.StartProxy(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Stream s, string c, string h) => $"http://proxy/{Guid.NewGuid()}"); + + var fakeWindow = new smoc.Tests.Fakes.FakeMainWindow(); + using var queue = new StandardPlaybackQueueService(fakeWindow, mockStreaming.Object, sut); + + queue.QueueNext(new[] { song1, song2 }); + await queue.Play(); + + // Allow preloading task to complete + await Task.Delay(100, TestContext.Current.CancellationToken); + + Assert.Equal(song1, queue.CurrentSong); + Assert.Equal(PlaybackState.Playing, queue.PlaybackState); + Assert.Equal(new[] { song1.Title }, loadedMediaTitles); + + // Simulate Chromecast finishing song 1 + _mockClient.Raise(c => c.MediaStatusChanged += null, _mockClient.Object, new MediaStatus { + PlayerState = PlayerStateType.Idle, + IdleReason = "FINISHED" + }); + + // Allow track transition to complete + await Task.Delay(200, TestContext.Current.CancellationToken); + + Assert.Equal(song2, queue.CurrentSong); + Assert.Equal(PlaybackState.Playing, queue.PlaybackState); + Assert.Equal(new[] { song1.Title, song2.Title }, loadedMediaTitles); + _mockClient.Verify(c => c.StopAsync(), Times.Never); + } +} diff --git a/smoc.Tests/Services/Audio/Cast/CastPlaybackServiceTest.cs b/smoc.Tests/Services/Audio/Cast/CastPlaybackServiceTest.cs new file mode 100644 index 0000000..78fc69b --- /dev/null +++ b/smoc.Tests/Services/Audio/Cast/CastPlaybackServiceTest.cs @@ -0,0 +1,309 @@ +using Moq; +using Sharpcaster.Models.Media; +using Smoc.Services.Audio.Cast; +using Smoc.Services.Cast; +using Smoc.Streaming; +using smoc.Tests.TestInfra; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace smoc.Tests.Services.Audio.Cast; + +public class CastPlaybackServiceTest { + private readonly Mock _mockProxyService; + private readonly Mock _mockClient; + private readonly Song _song; + private readonly MemoryStream _stream; + private readonly string _url = "http://proxy/stream"; + + public CastPlaybackServiceTest() { + _mockProxyService = new Mock(); + _mockClient = new Mock(); + _song = EntityTestFactory.GenerateSong(); + _stream = new MemoryStream(); + } + + [Fact] + public void InitialState_IsStopped() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + Assert.Equal(Smoc.Services.PlaybackState.Stopped, sut.PlaybackState); + Assert.Equal(_song, sut.Song); + Assert.Equal(TimeSpan.Zero, sut.CurrentTime); + } + + [Fact] + public async Task Play_UpdatesStateToPlaying() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + Assert.Equal(Smoc.Services.PlaybackState.Playing, sut.PlaybackState); + _mockClient.Verify(c => c.LoadAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Pause_UpdatesStateToPaused() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + sut.Pause(); + await sut.WaitForPendingCommandsAsync(); + + Assert.Equal(Smoc.Services.PlaybackState.Paused, sut.PlaybackState); + _mockClient.Verify(c => c.PauseAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Stop_UpdatesStateToStopped() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + sut.Stop(); + await sut.WaitForPendingCommandsAsync(); + + Assert.Equal(Smoc.Services.PlaybackState.Stopped, sut.PlaybackState); + _mockClient.Verify(c => c.StopAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task StopThenPlay_ExecutesCommandsInStrictSequentialOrder() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + var executionLog = new List(); + var stopTcs = new TaskCompletionSource(); + + var stopStartedTcs = new TaskCompletionSource(); + + _mockClient.Setup(c => c.StopAsync(It.IsAny())).Returns(async () => { + executionLog.Add("StopStarted"); + stopStartedTcs.SetResult(); + await stopTcs.Task; + executionLog.Add("StopFinished"); + }); + + _mockClient.Setup(c => c.LoadAsync(It.IsAny(), It.IsAny())).Returns(() => { + executionLog.Add("PlayStarted"); + return Task.CompletedTask; + }); + + sut.Stop(); + sut.Play(); + + await stopStartedTcs.Task; + + // Verify Stop was initiated but Play has not started because Stop is still executing + Assert.Equal(new[] { "StopStarted" }, executionLog); + + // Release StopAsync to complete + stopTcs.SetResult(); + await sut.WaitForPendingCommandsAsync(); + + // Verify strict serial execution order + Assert.Equal(new[] { "StopStarted", "StopFinished", "PlayStarted" }, executionLog); + } + + [Fact] + public async Task FaultedCommand_DoesNotBreakSubsequentCommands() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + _mockClient.Setup(c => c.StopAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("Network failure")); + _mockClient.Setup(c => c.LoadAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + sut.Stop(); + sut.Play(); + + await sut.WaitForPendingCommandsAsync(); + + _mockClient.Verify(c => c.StopAsync(It.IsAny()), Times.Once); + _mockClient.Verify(c => c.LoadAsync(It.IsAny(), It.IsAny()), Times.Once); + Assert.Equal(Smoc.Services.PlaybackState.Playing, sut.PlaybackState); + } + + [Fact] + public async Task CurrentTime_AdvancesWhilePlaying() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + // Wait a short duration for the progress tracking timer to advance + await Task.Delay(100, TestContext.Current.CancellationToken); + + Assert.True(sut.CurrentTime > TimeSpan.Zero, "CurrentTime should advance while in Playing state."); + Assert.True(sut.Progress > 0, "Progress should be greater than zero."); + } + + [Fact] + public async Task CurrentTime_FreezesOnPause() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + await Task.Delay(100, TestContext.Current.CancellationToken); + + sut.Pause(); + await sut.WaitForPendingCommandsAsync(); + + var pausedTime = sut.CurrentTime; + await Task.Delay(100, TestContext.Current.CancellationToken); + + Assert.Equal(pausedTime, sut.CurrentTime); + } + + [Fact] + public async Task CurrentTime_CalibratesOnMediaStatusChanged() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + // Trigger MediaStatusChanged event from client with specific currentTime + _mockClient.Raise(c => c.MediaStatusChanged += null, _mockClient.Object, new MediaStatus { + CurrentTime = 42.5, + PlayerState = PlayerStateType.Playing, + Media = new Media { Duration = 180.0 } + }); + + Assert.True(sut.CurrentTime >= TimeSpan.FromSeconds(42.5)); + Assert.Equal(TimeSpan.FromSeconds(180.0), sut.Duration); + } + + [Fact] + public async Task Seek_UpdatesCurrentTime() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + sut.Seek(TimeSpan.FromSeconds(60)); + await sut.WaitForPendingCommandsAsync(); + + Assert.True(sut.CurrentTime >= TimeSpan.FromSeconds(60)); + } + + [Fact] + public async Task Play_WhenPaused_InvokesPlayAsync() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + sut.Pause(); + await sut.WaitForPendingCommandsAsync(); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + _mockClient.Verify(c => c.PlayAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task MediaStatus_Finished_FiresSongEndedEvent() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + bool songEndedFired = false; + sut.SongEnded += (sender, args) => songEndedFired = true; + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + _mockClient.Raise(c => c.MediaStatusChanged += null, _mockClient.Object, new MediaStatus { + IdleReason = "FINISHED" + }); + + Assert.True(songEndedFired); + } + + [Fact] + public void MediaStatus_IgnoredWhenServiceNotStarted() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + bool songEndedFired = false; + sut.SongEnded += (sender, args) => songEndedFired = true; + + // Simulate device finishing another track before this service has played + _mockClient.Raise(c => c.MediaStatusChanged += null, _mockClient.Object, new MediaStatus { + PlayerState = PlayerStateType.Idle, + IdleReason = "FINISHED" + }); + + Assert.False(songEndedFired); + Assert.Equal(Smoc.Services.PlaybackState.Stopped, sut.PlaybackState); + } + + [Fact] + public async Task MediaStatus_IdleState_ResetsCurrentTimeAndStopsTracking() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + _mockClient.Raise(c => c.MediaStatusChanged += null, _mockClient.Object, new MediaStatus { + PlayerState = PlayerStateType.Playing, + CurrentTime = 42.0 + }); + Assert.True(sut.CurrentTime >= TimeSpan.FromSeconds(42)); + + _mockClient.Raise(c => c.MediaStatusChanged += null, _mockClient.Object, new MediaStatus { + PlayerState = PlayerStateType.Idle, + IdleReason = "FINISHED" + }); + + Assert.Equal(Smoc.Services.PlaybackState.Stopped, sut.PlaybackState); + Assert.Equal(TimeSpan.Zero, sut.CurrentTime); + } + + [Fact] + public void SpectrumData_ReturnsEmptyArrayAndCanSetIsSpectrumActive() { + using var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + Assert.Empty(sut.SpectrumData); + Assert.False(sut.IsSpectrumActive); + + sut.IsSpectrumActive = true; + Assert.True(sut.IsSpectrumActive); + } + + [Fact] + public async Task Play_InvokesEnsureConnection_BeforeLoadingMedia() { + bool ensureConnectionInvoked = false; + Func ensureConn = () => { + ensureConnectionInvoked = true; + return Task.CompletedTask; + }; + + using var sut = new CastPlaybackService( + _mockClient.Object, + _song, + _stream, + _url, + _mockProxyService.Object, + "audio/mpeg", + ensureConn); + + sut.Play(); + await sut.WaitForPendingCommandsAsync(); + + Assert.True(ensureConnectionInvoked); + _mockClient.Verify(c => c.LoadAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Dispose_StopsProxyAndDisposesStream() { + var sut = new CastPlaybackService(_mockClient.Object, _song, _stream, _url, _mockProxyService.Object); + + sut.Dispose(); + + _mockProxyService.Verify(p => p.StopProxy(_url), Times.Once); + Assert.Throws(() => _stream.Read(new byte[1], 0, 1)); + } +} diff --git a/smoc.Tests/Services/Cast/CastDiscoveryServiceTest.cs b/smoc.Tests/Services/Cast/CastDiscoveryServiceTest.cs new file mode 100644 index 0000000..e559508 --- /dev/null +++ b/smoc.Tests/Services/Cast/CastDiscoveryServiceTest.cs @@ -0,0 +1,60 @@ +using Sharpcaster.Models; +using Smoc.Services.Cast; +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace smoc.Tests.Services.Cast; + +/// +/// Unit tests for . +/// +public class CastDiscoveryServiceTest : IDisposable { + private readonly CastDiscoveryService _sut; + + public CastDiscoveryServiceTest() { + _sut = new CastDiscoveryService(); + } + + [Fact] + public void DiscoveredDevices_InitiallyEmpty() { + Assert.Empty(_sut.DiscoveredDevices); + } + + [Fact] + public async Task EnsureInitialDiscoveryCompletedAsync_CompletesSafelyWhenNotStarted() { + await _sut.EnsureInitialDiscoveryCompletedAsync(TestContext.Current.CancellationToken); + Assert.Empty(_sut.DiscoveredDevices); + } + + [Fact] + public async Task StartDiscoveryAsync_InitiatesScan() { + var task = _sut.StartDiscoveryAsync(TestContext.Current.CancellationToken); + Assert.NotNull(task); + await _sut.EnsureInitialDiscoveryCompletedAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ScanAsync_ReturnsEnumerableOfDevices() { + var devices = await _sut.ScanAsync(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + Assert.NotNull(devices); + } + + [Fact] + public async Task StartDiscoveryAsync_WhenCanceled_InitiatesNewScan() { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var task1 = _sut.StartDiscoveryAsync(cts.Token); + Assert.True(task1.IsCanceled); + + var task2 = _sut.StartDiscoveryAsync(TestContext.Current.CancellationToken); + Assert.NotSame(task1, task2); + await _sut.EnsureInitialDiscoveryCompletedAsync(TestContext.Current.CancellationToken); + } + + public void Dispose() { + _sut.Dispose(); + } +} diff --git a/smoc.Tests/Services/Cast/StreamingProxyServiceTest.cs b/smoc.Tests/Services/Cast/StreamingProxyServiceTest.cs new file mode 100644 index 0000000..de8a1cd --- /dev/null +++ b/smoc.Tests/Services/Cast/StreamingProxyServiceTest.cs @@ -0,0 +1,240 @@ +using Smoc.Services.Cast; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; + +namespace smoc.Tests.Services.Cast; + +public class StreamingProxyServiceTest : IDisposable { + private readonly StreamingProxyService _sut; + private readonly HttpClient _httpClient; + + public StreamingProxyServiceTest() { + _sut = new StreamingProxyService(); + _httpClient = new HttpClient(); + } + + [Fact] + public async Task StartProxy_ReturnsValidUrl() { + var stream = new MemoryStream(new byte[] { 1, 2, 3, 4 }); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + Assert.NotNull(url); + Assert.StartsWith("http://", url); + Assert.Contains("/stream/", url); + } + + [Fact] + public async Task Proxy_ServesStreamContent() { + var data = new byte[] { 1, 2, 3, 4, 5 }; + var stream = new MemoryStream(data); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + var response = await _httpClient.GetAsync(url, TestContext.Current.CancellationToken); + Assert.True(response.IsSuccessStatusCode); + Assert.Equal("audio/mpeg", response.Content.Headers.ContentType?.MediaType); + + var responseData = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Equal(data, responseData); + } + + [Fact] + public async Task Proxy_ServesStreamContent_MultipleTimes() { + var data = new byte[] { 1, 2, 3, 4, 5 }; + var stream = new MemoryStream(data); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + // First request + var response1 = await _httpClient.GetAsync(url, TestContext.Current.CancellationToken); + Assert.True(response1.IsSuccessStatusCode); + var responseData1 = await response1.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Equal(data, responseData1); + + // Second request (should succeed and seek back to beginning) + var response2 = await _httpClient.GetAsync(url, TestContext.Current.CancellationToken); + Assert.True(response2.IsSuccessStatusCode); + var responseData2 = await response2.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Equal(data, responseData2); + } + + [Fact] + public async Task Proxy_SupportsConcurrentStreams() { + var data1 = new byte[] { 10, 20, 30 }; + var data2 = new byte[] { 40, 50, 60, 70 }; + var stream1 = new MemoryStream(data1); + var stream2 = new MemoryStream(data2); + + var url1 = _sut.StartProxy(stream1, "audio/mpeg"); + var url2 = _sut.StartProxy(stream2, "audio/flac"); + + var response1 = await _httpClient.GetAsync(url1, TestContext.Current.CancellationToken); + var response2 = await _httpClient.GetAsync(url2, TestContext.Current.CancellationToken); + + Assert.True(response1.IsSuccessStatusCode); + Assert.True(response2.IsSuccessStatusCode); + Assert.Equal("audio/mpeg", response1.Content.Headers.ContentType?.MediaType); + Assert.Equal("audio/flac", response2.Content.Headers.ContentType?.MediaType); + + var bytes1 = await response1.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + var bytes2 = await response2.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + + Assert.Equal(data1, bytes1); + Assert.Equal(data2, bytes2); + } + + [Fact] + public async Task Proxy_ServesRangeRequests() { + var data = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + var stream = new MemoryStream(data); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Range = new RangeHeaderValue(2, 5); + + var response = await _httpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.Equal("bytes 2-5/10", response.Content.Headers.ContentRange?.ToString()); + + var responseData = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Equal(new byte[] { 2, 3, 4, 5 }, responseData); + } + + [Fact] + public async Task HandleRequest_PostMethod_ReturnsMethodNotAllowed() { + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + var response = await _httpClient.PostAsync(url, new StringContent("test"), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } + + [Fact] + public async Task HandleRequest_InvalidStreamId_ReturnsNotFound() { + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + var url = _sut.StartProxy(stream, "audio/mpeg"); + var nonExistentUrl = url + "-non-existent"; + + var response = await _httpClient.GetAsync(nonExistentUrl, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task HandleRequest_HeadMethod_ReturnsHeadersWithZeroBody() { + var data = new byte[] { 1, 2, 3, 4, 5 }; + var stream = new MemoryStream(data); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + var request = new HttpRequestMessage(HttpMethod.Head, url); + var response = await _httpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("audio/mpeg", response.Content.Headers.ContentType?.MediaType); + var body = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Empty(body); + } + + [Fact] + public async Task HandleRequest_InvalidRange_ReturnsRangeNotSatisfiable() { + var data = new byte[] { 1, 2, 3 }; + var stream = new MemoryStream(data); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Range = new RangeHeaderValue(100, 200); + + var response = await _httpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); + } + + [Fact] + public async Task StopProxy_RemovesStreamEndpoint() { + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + _sut.StopProxy(url); + + var response = await _httpClient.GetAsync(url, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public void StartProxy_WithTargetHost_ResolvesLocalIpViaUdpProbe() { + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + var url = _sut.StartProxy(stream, "audio/mpeg", "8.8.8.8"); + + Assert.NotNull(url); + Assert.StartsWith("http://", url); + Assert.Equal(url, _sut.CurrentProxyUrl); + } + + [Fact] + public async Task Proxy_ServesSuffixByteRange() { + var data = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + var stream = new MemoryStream(data); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Range = new RangeHeaderValue(null, 3); // bytes=-3 (last 3 bytes) + + var response = await _httpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.Equal("bytes 7-9/10", response.Content.Headers.ContentRange?.ToString()); + + var responseData = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Equal(new byte[] { 7, 8, 9 }, responseData); + } + + [Fact] + public async Task Proxy_ServesFromOffsetToByteEnd() { + var data = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + var stream = new MemoryStream(data); + var url = _sut.StartProxy(stream, "audio/mpeg"); + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Range = new RangeHeaderValue(7, null); // bytes=7- + + var response = await _httpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.Equal("bytes 7-9/10", response.Content.Headers.ContentRange?.ToString()); + + var responseData = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Equal(new byte[] { 7, 8, 9 }, responseData); + } + + [Fact] + public async Task Proxy_ServesNonSeekableStream_WithChunkedTransfer() { + var data = new byte[] { 1, 2, 3, 4, 5 }; + using var memStream = new MemoryStream(data); + using var nonSeekableStream = new NonSeekableStreamWrapper(memStream); + var url = _sut.StartProxy(nonSeekableStream, "audio/mpeg"); + + var response = await _httpClient.GetAsync(url, TestContext.Current.CancellationToken); + Assert.True(response.IsSuccessStatusCode); + Assert.True(response.Headers.TransferEncodingChunked ?? false); + + var responseData = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + Assert.Equal(data, responseData); + } + + private sealed class NonSeekableStreamWrapper(Stream inner) : Stream { + public override bool CanRead => inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => inner.CanWrite; + public override long Length => throw new NotSupportedException(); + public override long Position { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + public override void Flush() => inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => inner.ReadAsync(buffer, cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + } + + public void Dispose() { + _sut.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/smoc.Tests/Services/CommandServiceTest.cs b/smoc.Tests/Services/CommandServiceTest.cs index 24db5f0..5971c5a 100644 --- a/smoc.Tests/Services/CommandServiceTest.cs +++ b/smoc.Tests/Services/CommandServiceTest.cs @@ -98,4 +98,60 @@ public void RegisterCommand_DuplicateCommand_ThrowsException() { commandService.RegisterCommand("known", (cmd, __) => { }); Assert.Throws(() => commandService.RegisterCommand("known", (cmd, __) => { })); } + + [Fact] + public void GetCompletions_NoCompleter_ReturnsMatchingCommands() { + var commandService = new CommandService(); + commandService.RegisterCommand("apple", (cmd, __) => { }); + commandService.RegisterCommand("apply", (cmd, __) => { }); + commandService.RegisterCommand("banana", (cmd, __) => { }); + + var completions = commandService.GetCompletions("app"); + Assert.Equal(["apple", "apply"], completions.OrderBy(c => c)); + } + + [Fact] + public void GetCompletions_WithCompleter_ReturnsMatchingArgs() { + var commandService = new CommandService(); + commandService.RegisterCommand("fruit", (cmd, __) => { }); + commandService.RegisterCompleter("fruit", (cmd, args) => { + string[] fruits = ["apple", "banana", "cherry"]; + return fruits.Where(f => f.StartsWith(args)); + }); + + var completions = commandService.GetCompletions("fruit/a"); + Assert.Equal(["apple"], completions); + } + + [Fact] + public void GetCompletions_UnknownCommandWithArgs_ReturnsEmpty() { + var commandService = new CommandService(); + var completions = commandService.GetCompletions("unknown/args"); + Assert.Empty(completions); + } + + [Fact] + public void GetCompletions_CommandWithoutSlash_ReturnsCommandNameNotArgs() { + var commandService = new CommandService(); + commandService.RegisterCommand("output", (cmd, __) => { }); + commandService.RegisterCompleter("output", (cmd, args) => ["local", "refresh"]); + + var completions = commandService.GetCompletions("output"); + Assert.Equal(["output"], completions); + } + + [Fact] + public void UnregisterCompleter_RemovesCompleter() { + var commandService = new CommandService(); + commandService.RegisterCommand("fruit", (cmd, __) => { }); + commandService.RegisterCompleter("fruit", (cmd, args) => ["apple", "banana"]); + + var completionsBefore = commandService.GetCompletions("fruit/a"); + Assert.Equal(["apple", "banana"], completionsBefore); + + commandService.UnregisterCompleter("fruit"); + + var completionsAfter = commandService.GetCompletions("fruit/a"); + Assert.Empty(completionsAfter); + } } \ No newline at end of file diff --git a/smoc.Tests/Services/StandardPlaybackQueueServiceTest.cs b/smoc.Tests/Services/StandardPlaybackQueueServiceTest.cs index 33199ed..2dab765 100644 --- a/smoc.Tests/Services/StandardPlaybackQueueServiceTest.cs +++ b/smoc.Tests/Services/StandardPlaybackQueueServiceTest.cs @@ -1036,6 +1036,35 @@ public async Task Play_PreloadedSong_UsesPreloadedService() { _mockStreamingClient.Verify(c => c.GetSongStreamAsync(song2.Id, It.IsAny()), Times.Once); } + [Fact] + public async Task SetAudioServiceAsync_SwitchesServiceAndResumes() { + var song = EntityTestFactory.GenerateSong(); + var fakePlayerService1 = new FakePlaybackService(song); + var fakePlayerService2 = new FakePlaybackService(song); + var mockAudioService2 = new Mock(); + + _mockAudioService.SetupProperty(a => a.Volume, 0.4f); + mockAudioService2.SetupProperty(a => a.Volume, 1.0f); + _mockAudioService.Setup(a => a.MakePlaybackService(song, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(fakePlayerService1); + mockAudioService2.Setup(a => a.MakePlaybackService(song, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(fakePlayerService2); + _mockStreamingClient.Setup(c => c.GetSongStreamAsync(song.Id, It.IsAny())) + .ReturnsAsync(new SongStream(song.Id, "m4a", new MemoryStream())); + + using var sut = NewStandardPlaybackQueue(); + sut.QueueNext([song]); + await sut.Play(); + fakePlayerService1.SetCurrentTime(TimeSpan.FromSeconds(30)); + + await sut.SetAudioServiceAsync(mockAudioService2.Object); + + Assert.Equal(PlaybackState.Playing, fakePlayerService2.PlaybackState); + Assert.Equal(TimeSpan.FromSeconds(30), fakePlayerService2.CurrentTime); + Assert.Equal(0.4f, mockAudioService2.Object.Volume); + _mockAudioService.Verify(a => a.Dispose(), Times.Once); + } + [Fact] public async Task OnSongEnded_PreloadedTrackPlaysImmediately() { var song1 = EntityTestFactory.GenerateSong(id: "1", postfix: "1"); diff --git a/smoc.Tests/Ui/CommandLineTest.cs b/smoc.Tests/Ui/CommandLineTest.cs index 37c6fdf..2cff415 100644 --- a/smoc.Tests/Ui/CommandLineTest.cs +++ b/smoc.Tests/Ui/CommandLineTest.cs @@ -1,4 +1,5 @@ using smoc.Tests.TestInfra; +using Smoc.Services; using Smoc.Ui; using Terminal.Gui.Input; using Terminal.Gui.Views; @@ -127,4 +128,19 @@ public void TabPressed_DoesNotAdvanceFocus() { Assert.True(commandLine.HasFocus); } + [Fact] + public void TabPressed_WithCompletions_CompletesText() { + var commandService = new CommandService(); + commandService.RegisterCommand("test", (_, __) => { }); + + using var context = NewContext(); + var commandLine = new CommandLine(commandService); + context.Add(commandLine); + + context.KeyDown(Key.T).KeyDown(Key.E).KeyDown(Key.Tab); + + Assert.True(commandLine.HasFocus); + Assert.Equal(":test/", commandLine.CommandText); + } + } \ No newline at end of file diff --git a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs index c737276..050c986 100644 --- a/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs +++ b/smoc.Tests/Ui/Components/FrequencyHistogramViewTest.cs @@ -162,7 +162,7 @@ public void VisualizerFps_DynamicChange_RecreatesTimerWithNewInterval() { Assert.All(initialAmplitudes, amp => Assert.Equal(0f, amp)); // Advance by 100ms: the 10 FPS timer should trigger and procedural values should be populated - context.AdvanceTime(TimeSpan.FromMilliseconds(100)); + context.AdvanceTime(TimeSpan.FromMilliseconds(101)); var amplitudesAfter100ms = (float[]?)amplitudesField.GetValue(view); Assert.NotNull(amplitudesAfter100ms); diff --git a/smoc/Services/Audio/Cast/CastAudioService.cs b/smoc/Services/Audio/Cast/CastAudioService.cs new file mode 100644 index 0000000..3758cd7 --- /dev/null +++ b/smoc/Services/Audio/Cast/CastAudioService.cs @@ -0,0 +1,129 @@ +using Sharpcaster.Models; +using Smoc.Services.Cast; +using Smoc.Streaming; +using Smoc.Services.Audio; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System; + +namespace Smoc.Services.Audio.Cast; + +/// +/// Audio service that plays audio on a Google Cast device. +/// +public sealed class CastAudioService : IAudioService { + /// + /// The default media receiver application ID. + /// + public const string DefaultMediaReceiverAppId = "CC1AD845"; + + private readonly ChromecastReceiver _device; + private readonly IStreamingProxyService _proxyService; + private readonly IChromecastClient _client; + private readonly SemaphoreSlim _connectionLock = new(1, 1); + private readonly CancellationTokenSource _disposeCts = new(); + private float _volume = 1.0f; + + /// + /// Initializes a new instance of the class. + /// + /// The target Chromecast receiver. + /// The streaming proxy service. + /// Optional Chromecast client wrapper. + public CastAudioService( + ChromecastReceiver device, + IStreamingProxyService proxyService, + IChromecastClient? client = null) { + _device = device; + _proxyService = proxyService; + _client = client ?? new ChromecastClientWrapper(); + _proxyService.TargetHost = _device.DeviceUri?.Host; + } + + /// + public float Volume { + get => _volume; + set { + _volume = Math.Clamp(value, 0.0f, 1.0f); + _ = _client.SetVolumeAsync(_volume, _disposeCts.Token).ContinueWith(t => { + if (t.IsFaulted && t.Exception != null) { + Terminal.Gui.App.Logging.Error($"Failed to set Cast volume: {t.Exception.InnerException?.Message ?? t.Exception.Message}"); + } + }, TaskScheduler.Default); + } + } + + /// + /// Ensures that the client is connected to the receiver and the default media receiver app is running. + /// + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + public async Task EnsureConnectedAsync(CancellationToken cancellationToken = default) { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_disposeCts.Token, cancellationToken); + await _connectionLock.WaitAsync(linkedCts.Token); + try { + await _client.EnsureConnectedAndLaunchedAsync(_device, DefaultMediaReceiverAppId, linkedCts.Token); + } finally { + try { + _connectionLock.Release(); + } catch (ObjectDisposedException) { + // Ignored if service disposed during connection attempt + } + } + } + + /// + /// Connects to the Chromecast device and launches the default media receiver. + /// + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + public async Task ConnectAsync(CancellationToken cancellationToken = default) { + await EnsureConnectedAsync(cancellationToken); + } + + /// + /// Creates a new playback service for the given stream and codec. + /// + public IPlaybackService MakePlaybackService( + Song song, + Stream stream, + string codec, + CancellationToken cancellationToken = default) => + MakePlaybackService(song, stream, codec, null, cancellationToken); + + /// + public IPlaybackService MakePlaybackService( + Song song, + Stream stream, + string codec, + float? loudnessDb, + CancellationToken cancellationToken = default) { + var mimeType = MapContentTypeToMimeType(codec); + var url = _proxyService.StartProxy(stream, mimeType); + return new CastPlaybackService(_client, song, stream, url, _proxyService, mimeType, EnsureConnectedAsync); + } + + internal static string MapContentTypeToMimeType(string contentType) { + var lower = contentType.ToLowerInvariant(); + if (lower.Contains("flac")) return "audio/flac"; + if (lower.Contains("m4a") || lower.Contains("mp4")) return "audio/mp4"; + if (lower.Contains("aac")) return "audio/aac"; + if (lower.Contains("ogg")) return "audio/ogg"; + if (lower.Contains("wav")) return "audio/wav"; + return "audio/mpeg"; + } + + /// + public void Dispose() { + _disposeCts.Cancel(); + try { + _client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1)); + } catch { + // Suppress exceptions on dispose + } + _client.Dispose(); + _connectionLock.Dispose(); + _disposeCts.Dispose(); + } +} diff --git a/smoc/Services/Audio/Cast/CastPlaybackService.cs b/smoc/Services/Audio/Cast/CastPlaybackService.cs new file mode 100644 index 0000000..2c84d9a --- /dev/null +++ b/smoc/Services/Audio/Cast/CastPlaybackService.cs @@ -0,0 +1,389 @@ +using Terminal.Gui.App; +using Sharpcaster.Models; +using Sharpcaster.Models.Media; +using Smoc.Services.Cast; +using Smoc.Streaming; +using Smoc.Services.Audio; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System; + +namespace Smoc.Services.Audio.Cast; + +/// +/// Playback service that streams audio to a Google Cast device. +/// +public sealed class CastPlaybackService : IPlaybackService { + private readonly IChromecastClient _client; + private readonly Song _song; + private readonly Stream _stream; + private readonly string _url; + private readonly IStreamingProxyService _proxyService; + private readonly string _contentType; + private readonly Func? _ensureConnection; + private readonly object _stateLock = new(); + private readonly object _progressLock = new(); + private readonly object _commandLock = new(); + private readonly CancellationTokenSource _disposeCts = new(); + private Task _lastCommandTask = Task.CompletedTask; + private PlaybackState _state = PlaybackState.Stopped; + private bool _hasStarted; + + private TimeSpan _statusPosition = TimeSpan.Zero; + private long _startTimestamp; + private TimeSpan _duration = TimeSpan.Zero; + private CancellationTokenSource? _progressCts; + private Task? _progressLoopTask; + + /// + public event EventHandler? SongEnded; + + /// + public event EventHandler? PositionChanged; + + /// + public event EventHandler? PlaybackStateChanged; + + /// + /// Initializes a new instance of the class. + /// + /// The Cast client. + /// The song to play. + /// The stream of the song. + /// The URL where the stream is proxied. + /// The proxy service. + /// The content type of the stream. + /// Optional delegate to ensure device connectivity and receiver application launch before playing. + public CastPlaybackService( + IChromecastClient client, + Song song, + Stream stream, + string url, + IStreamingProxyService proxyService, + string contentType = "audio/mpeg", + Func? ensureConnection = null) { + _client = client; + _song = song; + _stream = stream; + _url = url; + _proxyService = proxyService; + _contentType = contentType; + _ensureConnection = ensureConnection; + + _client.MediaStatusChanged += OnMediaStatusChanged; + } + + /// + /// Initializes a new instance of the class with a parameterless connection delegate. + /// + /// The Cast client. + /// The song to play. + /// The stream of the song. + /// The URL where the stream is proxied. + /// The proxy service. + /// The content type of the stream. + /// Delegate to ensure device connectivity. + public CastPlaybackService( + IChromecastClient client, + Song song, + Stream stream, + string url, + IStreamingProxyService proxyService, + string contentType, + Func ensureConnection) + : this(client, song, stream, url, proxyService, contentType, _ => ensureConnection()) { + } + + /// + public float[] SpectrumData => []; + + /// + public bool IsSpectrumActive { get; set; } + + /// + public TimeSpan CurrentTime { + get { + lock (_progressLock) { + if (_state == PlaybackState.Playing && _startTimestamp > 0) { + var elapsedSeconds = (Stopwatch.GetTimestamp() - _startTimestamp) / (double)Stopwatch.Frequency; + var calculated = _statusPosition + TimeSpan.FromSeconds(elapsedSeconds); + return calculated < Duration ? calculated : Duration; + } + return _statusPosition; + } + } + } + + /// + public TimeSpan Duration => _duration > TimeSpan.Zero ? _duration : _song.Duration; + + /// + public float Progress => Duration.TotalSeconds > 0 + ? (float)(CurrentTime.TotalSeconds / Duration.TotalSeconds) + : 0; + + /// + public PlaybackState PlaybackState { + get { + lock (_stateLock) { + return _state; + } + } + } + + /// + public Song Song => _song; + + private void EnqueueCommand(Func action, string operationName) { + lock (_commandLock) { + if (_disposeCts.IsCancellationRequested) { + return; + } + + _lastCommandTask = _lastCommandTask.ContinueWith(async prevTask => { + if (_disposeCts.IsCancellationRequested) { + return; + } + + try { + await prevTask; + } catch { + // Swallow previous exception to preserve pipeline ordering + } + + if (_disposeCts.IsCancellationRequested) { + return; + } + + try { + await action(); + } catch (Exception ex) when (ex is not OperationCanceledException) { + Logging.Error($"Error executing Cast playback command \"{operationName}\": {ex.Message}"); + } + }, TaskScheduler.Default).Unwrap(); + } + } + + /// + /// Waits for all pending enqueued commands to complete. + /// + /// A task representing the completion of all pending commands. + internal Task WaitForPendingCommandsAsync() { + lock (_commandLock) { + return _lastCommandTask; + } + } + + /// + public void Play() { + EnqueueCommand(async () => { + _hasStarted = true; + if (_state == PlaybackState.Playing) { + return; + } + + if (_ensureConnection != null) { + await _ensureConnection(_disposeCts.Token); + } + + if (_state == PlaybackState.Stopped) { + await _client.LoadAsync(new Media { + ContentUrl = _url, + ContentType = _contentType, + Metadata = new MusicTrackMetadata { + Title = _song.Title, + Artist = _song.Artist.Name + } + }, _disposeCts.Token); + } else { + await _client.PlayAsync(_disposeCts.Token); + } + UpdateState(PlaybackState.Playing); + StartProgressTracking(); + }, "Play"); + } + + /// + public void Pause() { + EnqueueCommand(async () => { + if (_state == PlaybackState.Paused || _state == PlaybackState.Stopped) { + return; + } + + await _client.PauseAsync(_disposeCts.Token); + StopProgressTracking(resetPosition: false); + UpdateState(PlaybackState.Paused); + PositionChanged?.Invoke(this, CurrentTime); + }, "Pause"); + } + + /// + public void Stop() { + EnqueueCommand(async () => { + if (_state == PlaybackState.Stopped) { + return; + } + + await _client.StopAsync(_disposeCts.Token); + StopProgressTracking(resetPosition: true); + UpdateState(PlaybackState.Stopped); + PositionChanged?.Invoke(this, TimeSpan.Zero); + }, "Stop"); + } + + /// + public void Seek(TimeSpan position) { + EnqueueCommand(async () => { + await _client.SeekAsync(position.TotalSeconds, _disposeCts.Token); + lock (_progressLock) { + _statusPosition = position; + if (_state == PlaybackState.Playing) { + _startTimestamp = Stopwatch.GetTimestamp(); + } + } + PositionChanged?.Invoke(this, position); + }, "Seek"); + } + + private void StartProgressTracking() { + lock (_progressLock) { + _startTimestamp = Stopwatch.GetTimestamp(); + if (_progressLoopTask != null && !_progressLoopTask.IsCompleted) { + return; + } + + _progressCts?.Cancel(); + _progressCts?.Dispose(); + _progressCts = new CancellationTokenSource(); + var token = _progressCts.Token; + + _progressLoopTask = Task.Run(async () => { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500)); + int pollCounter = 0; + try { + while (!token.IsCancellationRequested && await timer.WaitForNextTickAsync(token)) { + if (_state == PlaybackState.Playing) { + PositionChanged?.Invoke(this, CurrentTime); + + if (++pollCounter >= 20) { + pollCounter = 0; + _ = PollMediaStatusAsync(token); + } + } + } + } catch (OperationCanceledException) { + // Normal cancellation + } + }, CancellationToken.None); + } + } + + private void StopProgressTracking(bool resetPosition) { + lock (_progressLock) { + _progressCts?.Cancel(); + _progressCts?.Dispose(); + _progressCts = null; + _progressLoopTask = null; + + if (_state == PlaybackState.Playing && _startTimestamp > 0) { + var elapsedSeconds = (Stopwatch.GetTimestamp() - _startTimestamp) / (double)Stopwatch.Frequency; + _statusPosition += TimeSpan.FromSeconds(elapsedSeconds); + } + _startTimestamp = 0; + + if (resetPosition) { + _statusPosition = TimeSpan.Zero; + } + } + } + + private async Task PollMediaStatusAsync(CancellationToken cancellationToken) { + try { + var status = await _client.GetMediaStatusAsync(cancellationToken); + if (status != null) { + OnMediaStatusChanged(this, status); + } + } catch { + // Ignore polling errors + } + } + + private void OnMediaStatusChanged(object? sender, MediaStatus e) { + if (!_hasStarted) { + return; + } + + if (e.Media?.ContentUrl != null && + !string.Equals(e.Media.ContentUrl, _url, StringComparison.OrdinalIgnoreCase)) { + return; + } + + lock (_progressLock) { + if (e.CurrentTime >= 0) { + _statusPosition = TimeSpan.FromSeconds(e.CurrentTime); + if (_state == PlaybackState.Playing) { + _startTimestamp = Stopwatch.GetTimestamp(); + } + } + if (e.Media?.Duration != null && e.Media.Duration.Value > 0) { + _duration = TimeSpan.FromSeconds(e.Media.Duration.Value); + } + } + + PositionChanged?.Invoke(this, CurrentTime); + + var wasPlaying = false; + lock (_stateLock) { + wasPlaying = _state == PlaybackState.Playing; + } + + PlaybackState? newState = e.PlayerState switch { + PlayerStateType.Playing => PlaybackState.Playing, + PlayerStateType.Paused => PlaybackState.Paused, + PlayerStateType.Idle => PlaybackState.Stopped, + PlayerStateType.Buffering => PlaybackState.Playing, + _ => null + }; + + if (newState.HasValue) { + UpdateState(newState.Value); + if (newState.Value == PlaybackState.Stopped) { + StopProgressTracking(resetPosition: true); + } + } + + if (wasPlaying && string.Equals(e.IdleReason, "FINISHED", StringComparison.OrdinalIgnoreCase)) { + SongEnded?.Invoke(this, EventArgs.Empty); + } + } + + private void UpdateState(PlaybackState newState) { + bool changed; + lock (_stateLock) { + changed = _state != newState; + _state = newState; + } + if (changed) { + PlaybackStateChanged?.Invoke(this, newState); + } + } + + /// + public void Dispose() { + _disposeCts.Cancel(); + StopProgressTracking(resetPosition: false); + _client.MediaStatusChanged -= OnMediaStatusChanged; + + try { + _lastCommandTask.Wait(TimeSpan.FromSeconds(2)); + } catch { + // Ignore timeout or cancellation on dispose + } + + _proxyService.StopProxy(_url); + _stream.Dispose(); + _disposeCts.Dispose(); + } +} diff --git a/smoc/Services/Cast/CastDiscoveryService.cs b/smoc/Services/Cast/CastDiscoveryService.cs new file mode 100644 index 0000000..5bba7ea --- /dev/null +++ b/smoc/Services/Cast/CastDiscoveryService.cs @@ -0,0 +1,129 @@ +using Sharpcaster; +using Sharpcaster.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Smoc.Services.Cast; + +/// +/// Service for discovery of Google Cast devices using SharpCaster. +/// +public sealed class CastDiscoveryService : ICastDiscoveryService { + private readonly ChromecastLocator _locator; + private readonly List _discoveredDevices = new(); + private readonly object _devicesLock = new(); + private readonly CancellationTokenSource _disposeCts = new(); + private Task? _initialDiscoveryTask; + + /// + public event EventHandler? DeviceFound; + + /// + /// Initializes a new instance of the class. + /// + public CastDiscoveryService() { + _locator = new ChromecastLocator(); + _locator.ChromecastReceiverFound += OnReceiverFound; + } + + /// + public IEnumerable DiscoveredDevices { + get { + lock (_devicesLock) { + return _discoveredDevices.ToList(); + } + } + } + + /// + public Task StartDiscoveryAsync(CancellationToken cancellationToken = default) { + lock (_devicesLock) { + if (_initialDiscoveryTask == null || _initialDiscoveryTask.IsFaulted || _initialDiscoveryTask.IsCanceled) { + _initialDiscoveryTask = ScanInternalAsync( + quickTimeout: TimeSpan.FromMilliseconds(500), + mediumTimeout: TimeSpan.FromSeconds(1), + fullTimeout: TimeSpan.FromSeconds(2), + cancellationToken: cancellationToken); + } + return _initialDiscoveryTask; + } + } + + /// + public async Task EnsureInitialDiscoveryCompletedAsync(CancellationToken cancellationToken = default) { + Task? task; + lock (_devicesLock) { + task = _initialDiscoveryTask; + } + + if (task != null) { + try { + await task.WaitAsync(cancellationToken); + } catch (OperationCanceledException) { + throw; + } catch { + // Suppress scan exceptions to allow subsequent fallback logic + } + } + } + + /// + public async Task> ScanAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) { + var scanTimeout = timeout ?? TimeSpan.FromSeconds(2); + return await ScanInternalAsync( + quickTimeout: TimeSpan.FromMilliseconds(500), + mediumTimeout: scanTimeout, + fullTimeout: scanTimeout, + cancellationToken: cancellationToken); + } + + private async Task> ScanInternalAsync( + TimeSpan quickTimeout, + TimeSpan mediumTimeout, + TimeSpan fullTimeout, + CancellationToken cancellationToken = default) { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_disposeCts.Token, cancellationToken); + var receivers = await _locator.FindReceiversAsync(quickTimeout, mediumTimeout, fullTimeout).WaitAsync(linkedCts.Token); + var newlyDiscovered = new List(); + foreach (var device in receivers) { + if (AddDevice(device)) { + newlyDiscovered.Add(device); + } + } + return newlyDiscovered; + } + + private void OnReceiverFound(object? sender, ChromecastReceiverEventArgs e) { + AddDevice(e.Receiver); + } + + private bool AddDevice(ChromecastReceiver device) { + ChromecastReceiver? toNotify = null; + lock (_devicesLock) { + if (!_discoveredDevices.Any(d => d.DeviceUri == device.DeviceUri)) { + _discoveredDevices.Add(device); + toNotify = device; + } + } + + if (toNotify != null) { + DeviceFound?.Invoke(this, toNotify); + return true; + } + return false; + } + + /// + public void StopDiscovery() { + } + + /// + public void Dispose() { + _locator.ChromecastReceiverFound -= OnReceiverFound; + _disposeCts.Cancel(); + _disposeCts.Dispose(); + } +} diff --git a/smoc/Services/Cast/ChromecastClientWrapper.cs b/smoc/Services/Cast/ChromecastClientWrapper.cs new file mode 100644 index 0000000..a42fd17 --- /dev/null +++ b/smoc/Services/Cast/ChromecastClientWrapper.cs @@ -0,0 +1,120 @@ +using Sharpcaster; +using Sharpcaster.Models; +using Sharpcaster.Models.Media; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Smoc.Services.Cast; + +/// +/// Wrapper for the SharpCaster . +/// +public sealed class ChromecastClientWrapper : IChromecastClient { + private readonly ChromecastClient _client = new(); + private bool _isConnected; + + /// + /// Initializes a new instance of the class. + /// + public ChromecastClientWrapper() { + _client.Disconnected += (_, _) => _isConnected = false; + } + + /// + public event EventHandler? MediaStatusChanged { + add => _client.MediaChannel.StatusChanged += value; + remove => _client.MediaChannel.StatusChanged -= value; + } + + /// + public float Volume { + get => (float)(_client.ChromecastStatus?.Volume?.Level ?? 0); + set => _client.ReceiverChannel.SetVolume(value); + } + + /// + public async Task SetVolumeAsync(float level, CancellationToken cancellationToken = default) { + await _client.ReceiverChannel.SetVolume(level).WaitAsync(cancellationToken); + } + + /// + public async Task EnsureConnectedAndLaunchedAsync(ChromecastReceiver receiver, string applicationId, CancellationToken cancellationToken = default) { + if (!_isConnected) { + await _client.ConnectChromecast(receiver).WaitAsync(cancellationToken); + _isConnected = true; + } + + try { + var status = await _client.ReceiverChannel.GetChromecastStatusAsync().WaitAsync(cancellationToken); + var isRunning = false; + if (status?.Applications != null) { + foreach (var app in status.Applications) { + if (string.Equals(app.AppId, applicationId, StringComparison.OrdinalIgnoreCase)) { + isRunning = true; + break; + } + } + } + + if (!isRunning) { + await _client.LaunchApplicationAsync(applicationId).WaitAsync(cancellationToken); + } + } catch (OperationCanceledException) { + throw; + } catch { + _isConnected = false; + await _client.ConnectChromecast(receiver).WaitAsync(cancellationToken); + _isConnected = true; + await _client.LaunchApplicationAsync(applicationId).WaitAsync(cancellationToken); + } + } + + /// + public async Task ConnectChromecast(ChromecastReceiver receiver, CancellationToken cancellationToken = default) { + await _client.ConnectChromecast(receiver).WaitAsync(cancellationToken); + _isConnected = true; + } + + /// + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { + _isConnected = false; + await _client.DisconnectAsync().WaitAsync(cancellationToken); + } + + /// + public Task LaunchApplicationAsync(string applicationId, CancellationToken cancellationToken = default) => + _client.LaunchApplicationAsync(applicationId).WaitAsync(cancellationToken); + + /// + public Task LoadAsync(Media media, CancellationToken cancellationToken = default) => + _client.MediaChannel.LoadAsync(media).WaitAsync(cancellationToken); + + /// + public Task PlayAsync(CancellationToken cancellationToken = default) => + _client.MediaChannel.PlayAsync().WaitAsync(cancellationToken); + + /// + public Task PauseAsync(CancellationToken cancellationToken = default) => + _client.MediaChannel.PauseAsync().WaitAsync(cancellationToken); + + /// + public Task StopAsync(CancellationToken cancellationToken = default) => + _client.MediaChannel.StopAsync().WaitAsync(cancellationToken); + + /// + public Task SeekAsync(double seconds, CancellationToken cancellationToken = default) => + _client.MediaChannel.SeekAsync(seconds).WaitAsync(cancellationToken); + + /// + public async Task GetMediaStatusAsync(CancellationToken cancellationToken = default) { + try { + return await _client.MediaChannel.GetMediaStatusAsync().WaitAsync(cancellationToken); + } catch { + return null; + } + } + + /// + public void Dispose() => _client.Dispose(); +} diff --git a/smoc/Services/Cast/ICastDiscoveryService.cs b/smoc/Services/Cast/ICastDiscoveryService.cs new file mode 100644 index 0000000..7564c9b --- /dev/null +++ b/smoc/Services/Cast/ICastDiscoveryService.cs @@ -0,0 +1,49 @@ +using Sharpcaster.Models; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Smoc.Services.Cast; + +/// +/// Interface for discovery of Google Cast devices. +/// +public interface ICastDiscoveryService : IDisposable { + /// + /// Occurs when a new Google Cast device is discovered. + /// + event EventHandler? DeviceFound; + + /// + /// Starts the discovery process. + /// + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task StartDiscoveryAsync(CancellationToken cancellationToken = default); + + /// + /// Stops the discovery process. + /// + void StopDiscovery(); + + /// + /// Actively scans for Google Cast devices on the network. + /// + /// Optional scan timeout duration. + /// A token to cancel the operation. + /// A task representing the asynchronous operation, returning newly discovered devices. + Task> ScanAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); + + /// + /// Ensures that the initial startup discovery scan has completed. + /// + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task EnsureInitialDiscoveryCompletedAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the list of currently discovered Google Cast devices. + /// + IEnumerable DiscoveredDevices { get; } +} diff --git a/smoc/Services/Cast/IChromecastClient.cs b/smoc/Services/Cast/IChromecastClient.cs new file mode 100644 index 0000000..483e722 --- /dev/null +++ b/smoc/Services/Cast/IChromecastClient.cs @@ -0,0 +1,107 @@ +using Sharpcaster.Models; +using Sharpcaster.Models.Media; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Smoc.Services.Cast; + +/// +/// Interface for a Google Cast client. +/// +public interface IChromecastClient : IDisposable { + /// + /// Occurs when the media status of the connected device changes. + /// + event EventHandler? MediaStatusChanged; + + /// + /// Ensures that the client is connected to the receiver and the specified application is running. + /// If disconnected or if the application has timed out to the backdrop screen, reconnects and relaunches. + /// + /// The receiver to connect to. + /// The ID of the application to launch. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task EnsureConnectedAndLaunchedAsync(ChromecastReceiver receiver, string applicationId, CancellationToken cancellationToken = default); + + /// + /// Connects to a Chromecast receiver. + /// + /// The receiver to connect to. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task ConnectChromecast(ChromecastReceiver receiver, CancellationToken cancellationToken = default); + + /// + /// Disconnects from the currently connected device. + /// + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task DisconnectAsync(CancellationToken cancellationToken = default); + + /// + /// Launches an application on the connected device. + /// + /// The ID of the application to launch. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task LaunchApplicationAsync(string applicationId, CancellationToken cancellationToken = default); + + /// + /// Sets the volume level of the connected device. + /// + /// The volume level (0.0 to 1.0). + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task SetVolumeAsync(float level, CancellationToken cancellationToken = default); + + /// + /// Loads media on the connected device. + /// + /// The media to load. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task LoadAsync(Media media, CancellationToken cancellationToken = default); + + /// + /// Starts playback on the connected device. + /// + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task PlayAsync(CancellationToken cancellationToken = default); + + /// + /// Pauses playback on the connected device. + /// + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task PauseAsync(CancellationToken cancellationToken = default); + + /// + /// Stops playback on the connected device. + /// + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task StopAsync(CancellationToken cancellationToken = default); + + /// + /// Seeks to a specific position in the media. + /// + /// The position to seek to, in seconds. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + Task SeekAsync(double seconds, CancellationToken cancellationToken = default); + + /// + /// Gets or sets the volume level of the connected device. + /// + float Volume { get; set; } + + /// + /// Requests the current media status from the connected device. + /// + /// A token to cancel the operation. + /// A task representing the asynchronous operation, returning the current media status or null. + Task GetMediaStatusAsync(CancellationToken cancellationToken = default); +} diff --git a/smoc/Services/Cast/IStreamingProxyService.cs b/smoc/Services/Cast/IStreamingProxyService.cs new file mode 100644 index 0000000..e9b620f --- /dev/null +++ b/smoc/Services/Cast/IStreamingProxyService.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; + +namespace Smoc.Services.Cast; + +/// +/// Interface for a service that proxies media streams over HTTP. +/// +public interface IStreamingProxyService : IDisposable { + /// + /// Gets or sets the target host used to discover the best local network interface IP. + /// + string? TargetHost { get; set; } + + /// + /// Starts proxying the specified stream and returns the URL to access it. + /// + /// The stream to proxy. + /// The content type of the stream. + /// The URL of the proxied stream. + string StartProxy(Stream stream, string contentType); + + /// + /// Starts proxying the specified stream towards a target host and returns the URL to access it. + /// + /// The stream to proxy. + /// The content type of the stream. + /// The optional target receiver host/IP to probe for the local IP. + /// The URL of the proxied stream. + string StartProxy(Stream stream, string contentType, string? targetHost); + + /// + /// Stops proxying a specific stream or all streams. + /// + /// The stream ID or proxy URL to stop; or null to stop all streams. + void StopProxy(string? streamIdOrUrl = null); + + /// + /// Gets the current proxy URL. + /// + string? CurrentProxyUrl { get; } +} diff --git a/smoc/Services/Cast/StreamingProxyService.cs b/smoc/Services/Cast/StreamingProxyService.cs new file mode 100644 index 0000000..a73bc1a --- /dev/null +++ b/smoc/Services/Cast/StreamingProxyService.cs @@ -0,0 +1,332 @@ +using Terminal.Gui.App; +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Smoc.Services.Util; + +namespace Smoc.Services.Cast; + +/// +/// Service that proxies media streams over HTTP for Chromecast playback. +/// +public sealed class StreamingProxyService : IStreamingProxyService { + private sealed record StreamEntry(Stream Stream, string ContentType, object Lock); + + private readonly ConcurrentDictionary _streams = new(StringComparer.OrdinalIgnoreCase); + private readonly object _listenerLock = new(); + private HttpListener? _listener; + private string? _currentUrl; + private string? _boundIp; + private int _boundPort; + private CancellationTokenSource? _cts; + private Task? _listenTask; + + /// + public string? TargetHost { get; set; } + + /// + public string? CurrentProxyUrl => _currentUrl; + + /// + public string StartProxy(Stream stream, string contentType) { + return StartProxy(stream, contentType, TargetHost); + } + + /// + public string StartProxy(Stream stream, string contentType, string? targetHost) { + if (!string.IsNullOrWhiteSpace(targetHost)) { + TargetHost = targetHost; + } + + var streamId = Guid.NewGuid().ToString("N"); + _streams[streamId] = new StreamEntry(stream, contentType, new object()); + + lock (_listenerLock) { + EnsureListenerRunning(); + _currentUrl = $"http://{_boundIp}:{_boundPort}/stream/{streamId}"; + Logging.Information($"StreamingProxyService stream registered at {_currentUrl}"); + } + + return _currentUrl; + } + + /// + public void StopProxy(string? streamIdOrUrl = null) { + if (string.IsNullOrWhiteSpace(streamIdOrUrl)) { + lock (_listenerLock) { + _cts?.Cancel(); + try { + _listener?.Stop(); + _listener?.Close(); + } catch { + // Ignored during cleanup + } + _listener = null; + _streams.Clear(); + _currentUrl = null; + _cts?.Dispose(); + _cts = null; + _listenTask = null; + } + return; + } + + var streamId = streamIdOrUrl.TrimEnd("/".ToCharArray()).Split("/".ToCharArray()).Last(); + _streams.TryRemove(streamId, out _); + _streams.TryRemove(streamIdOrUrl, out _); + } + + private void EnsureListenerRunning() { + if (_listener != null && _listener.IsListening) { + return; + } + + _boundPort = GetAvailablePort(); + _boundIp = GetLocalIPAddress(TargetHost); + + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://{_boundIp}:{_boundPort}/"); + _listener.Start(); + + _cts = new CancellationTokenSource(); + var token = _cts.Token; + _listenTask = Task.Run(() => ListenLoop(token)); + Logging.Information($"StreamingProxyService listening at http://{_boundIp}:{_boundPort}/"); + } + + private async Task ListenLoop(CancellationToken token) { + while (!token.IsCancellationRequested && _listener is { IsListening: true }) { + try { + var context = await _listener.GetContextAsync(); + _ = Task.Run(() => HandleRequest(context, token), token); + } catch (Exception ex) when (ex is HttpListenerException || ex is ObjectDisposedException) { + break; + } catch (Exception ex) { + Logging.Error($"StreamingProxy listener error: {ex.Message}"); + } + } + } + + private async Task HandleRequest(HttpListenerContext context, CancellationToken token) { + try { + var request = context.Request; + var response = context.Response; + + if (!request.HttpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase) && + !request.HttpMethod.Equals("HEAD", StringComparison.OrdinalIgnoreCase)) { + response.StatusCode = (int)HttpStatusCode.MethodNotAllowed; + response.Close(); + return; + } + + var path = request.Url?.AbsolutePath.Trim("/".ToCharArray()) ?? string.Empty; + StreamEntry? entry = null; + + if (path.StartsWith("stream/", StringComparison.OrdinalIgnoreCase)) { + var streamId = path["stream/".Length..]; + _streams.TryGetValue(streamId, out entry); + } else if (path.Equals("stream", StringComparison.OrdinalIgnoreCase)) { + entry = _streams.Values.LastOrDefault(); + } + + if (entry == null) { + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; + } + + var (stream, contentType, streamLock) = entry; + response.ContentType = contentType; + + if (stream.CanSeek) { + response.Headers.Set("Accept-Ranges", "bytes"); + long totalLength; + lock (streamLock) { + totalLength = stream.Length; + } + + var rangeHeader = request.Headers["Range"]; + if (!string.IsNullOrWhiteSpace(rangeHeader) && + rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase)) { + var rangeStr = rangeHeader["bytes=".Length..].Trim(); + long start = 0; + long end = totalLength - 1; + bool valid = true; + var dashIndex = rangeStr.IndexOf("-", StringComparison.Ordinal); + + if (dashIndex >= 0) { + var firstPart = rangeStr[..dashIndex].Trim(); + var secondPart = rangeStr[(dashIndex + 1)..].Trim(); + if (string.IsNullOrEmpty(firstPart)) { + if (long.TryParse(secondPart, out var suffix) && suffix > 0) { + start = Math.Max(0, totalLength - suffix); + end = totalLength - 1; + } else { + valid = false; + } + } else if (string.IsNullOrEmpty(secondPart)) { + if (long.TryParse(firstPart, out var s) && s >= 0 && s < totalLength) { + start = s; + end = totalLength - 1; + } else { + valid = false; + } + } else { + if (long.TryParse(firstPart, out var s) && + long.TryParse(secondPart, out var e) && + s >= 0 && s <= e && s < totalLength) { + start = s; + end = Math.Min(e, totalLength - 1); + } else { + valid = false; + } + } + } else { + valid = false; + } + + if (!valid) { + response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable; + response.Headers.Set("Content-Range", $"bytes */{totalLength}"); + response.Close(); + return; + } + + response.StatusCode = (int)HttpStatusCode.PartialContent; + response.Headers.Set("Content-Range", $"bytes {start}-{end}/{totalLength}"); + long rangeLength = end - start + 1; + response.ContentLength64 = rangeLength; + + if (request.HttpMethod.Equals("HEAD", StringComparison.OrdinalIgnoreCase)) { + response.Close(); + return; + } + + await StreamRangeAsync(stream, response.OutputStream, start, rangeLength, streamLock, token); + response.OutputStream.Close(); + return; + } + + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentLength64 = totalLength; + + if (request.HttpMethod.Equals("HEAD", StringComparison.OrdinalIgnoreCase)) { + response.Close(); + return; + } + + await StreamRangeAsync(stream, response.OutputStream, 0, totalLength, streamLock, token); + response.OutputStream.Close(); + } else { + response.StatusCode = (int)HttpStatusCode.OK; + response.SendChunked = true; + + if (request.HttpMethod.Equals("HEAD", StringComparison.OrdinalIgnoreCase)) { + response.Close(); + return; + } + + byte[] buffer = new byte[65536]; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), token)) > 0) { + await response.OutputStream.WriteAsync(buffer.AsMemory(0, bytesRead), token); + } + response.OutputStream.Close(); + } + } catch (Exception ex) when (ex is not OperationCanceledException) { + if (ex is not (HttpListenerException or IOException or ObjectDisposedException)) { + Logging.Error($"StreamingProxy error: {ex.Message}"); + } + } finally { + try { + context.Response.Close(); + } catch { + // Closed + } + } + } + + private static async Task StreamRangeAsync( + Stream stream, + Stream output, + long start, + long length, + object streamLock, + CancellationToken token) { + long bytesRemaining = length; + long currentPos = start; + byte[] buffer = new byte[65536]; + + while (bytesRemaining > 0 && !token.IsCancellationRequested) { + int toRead = (int)Math.Min(buffer.Length, bytesRemaining); + int bytesRead; + lock (streamLock) { + if (stream.Position != currentPos) { + stream.Seek(currentPos, SeekOrigin.Begin); + } + bytesRead = stream.Read(buffer, 0, toRead); + } + + if (bytesRead == 0) { + break; + } + + currentPos += bytesRead; + bytesRemaining -= bytesRead; + await output.WriteAsync(buffer.AsMemory(0, bytesRead), token); + } + } + + private static int GetAvailablePort() { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static string GetLocalIPAddress(string? targetHost) { + if (!string.IsNullOrWhiteSpace(targetHost)) { + try { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.Connect(targetHost, 65530); + if (socket.LocalEndPoint is IPEndPoint endPoint && + !IPAddress.IsLoopback(endPoint.Address) && + endPoint.Address.AddressFamily == AddressFamily.InterNetwork) { + return endPoint.Address.ToString(); + } + } catch { + // Fall back to interface enumeration + } + } + + var interfaces = NetworkInterface.GetAllNetworkInterfaces(); + foreach (var ni in interfaces) { + if (ni.OperationalStatus != OperationalStatus.Up || + ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) { + continue; + } + + var props = ni.GetIPProperties(); + foreach (var ip in props.UnicastAddresses) { + if (ip.Address.AddressFamily == AddressFamily.InterNetwork && + !IPAddress.IsLoopback(ip.Address)) { + return ip.Address.ToString(); + } + } + } + + return "127.0.0.1"; + } + + /// + public void Dispose() { + StopProxy(); + } +} diff --git a/smoc/Services/CommandService.cs b/smoc/Services/CommandService.cs index 7ef1b93..7677004 100644 --- a/smoc/Services/CommandService.cs +++ b/smoc/Services/CommandService.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using System.Linq; + namespace Smoc.Services; /// @@ -12,6 +16,15 @@ public sealed class CommandService { public delegate void CommandHandler(string command, string args); private readonly Dictionary commands = new(); + private readonly Dictionary completers = new(); + + /// + /// Delegate for handling command completion. + /// + /// The command name. + /// The arguments part of the command string. + /// A list of possible completions. + public delegate IEnumerable CompletionHandler(string command, string args); /// /// Registers a new command handler. @@ -29,6 +42,7 @@ public void RegisterCommand(string command, CommandHandler handler) { /// Unregisters a command handler. /// /// The command name to unregister. + /// Thrown if the command is not registered. public void UnregisterCommand(string command) { if (!commands.Remove(command)) throw new ArgumentException("Command not registered"); } @@ -53,6 +67,47 @@ public bool ExecuteCommand(string command) { return false; } + /// + /// Registers a new completion handler. + /// + /// The command name. + /// The handler to callback when completions are requested. + public void RegisterCompleter(string command, CompletionHandler handler) { + completers[command] = handler; + } + + /// + /// Unregisters a completion handler. + /// + /// The command name. + public void UnregisterCompleter(string command) { + completers.Remove(command); + } + + /// + /// Gets completions for a given command line. + /// + /// The full command line string. + /// A list of possible completions. + public IEnumerable GetCompletions(string command) { + var argCutoff = command.IndexOf('/'); + var commandName = command; + if (argCutoff > 0) { + commandName = command[..argCutoff]; + } + var args = argCutoff > 0 ? command[(argCutoff + 1)..] : string.Empty; + + if (argCutoff >= 0 && completers.TryGetValue(commandName, out var handler)) { + return handler(commandName, args); + } + + if (argCutoff <= 0) { + return commands.Keys.Where(c => c.StartsWith(commandName, StringComparison.OrdinalIgnoreCase)); + } + + return []; + } + /// /// Parses arguments from a command string. /// @@ -61,4 +116,4 @@ public bool ExecuteCommand(string command) { public static string[] GetArgs(string args) { return args.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } -} +} \ No newline at end of file diff --git a/smoc/Services/IPlaybackQueueService.cs b/smoc/Services/IPlaybackQueueService.cs index 9ecdbd3..b3fc48f 100644 --- a/smoc/Services/IPlaybackQueueService.cs +++ b/smoc/Services/IPlaybackQueueService.cs @@ -1,4 +1,5 @@ using Smoc.Streaming; +using Smoc.Services.Audio; namespace Smoc.Services; @@ -157,4 +158,10 @@ public interface IPlaybackQueueService : IDisposable { /// Seeks backward by a specified duration in the current track. /// void SeekBackward(TimeSpan duration); + + /// + /// Sets the audio service to use for playback. + /// + /// The audio service to use. + Task SetAudioServiceAsync(IAudioService audioService); } \ No newline at end of file diff --git a/smoc/Services/StandardPlaybackQueueService.cs b/smoc/Services/StandardPlaybackQueueService.cs index 626a73e..4863534 100644 --- a/smoc/Services/StandardPlaybackQueueService.cs +++ b/smoc/Services/StandardPlaybackQueueService.cs @@ -6,8 +6,11 @@ namespace Smoc.Services; +/// +/// Standard implementation of . +/// public sealed class StandardPlaybackQueueService : IPlaybackQueueService { - private readonly IAudioService _audioService; + private IAudioService _audioService; private readonly IMainWindow _mainWindow; private readonly IStreamingClient _streamingClient; @@ -355,6 +358,33 @@ public void SeekForward(TimeSpan duration) { SeekTo(targetPosition > Duration ? Duration : targetPosition); } + /// + public async Task SetAudioServiceAsync(IAudioService audioService) { + var wasPlaying = PlaybackState == PlaybackState.Playing; + var wasPaused = PlaybackState == PlaybackState.Paused; + var currentTime = CurrentTime; + var previousVolume = _audioService.Volume; + + Stop(); + _playbackService.Replace(null!); + _preloadedPlaybackService.Replace(null!); + _preloadingSong = null; + _preloadingTask = null; + + var oldAudioService = _audioService; + _audioService = audioService; + _audioService.Volume = previousVolume; + oldAudioService?.Dispose(); + + if (_playbackQueue.Count > 0 && (wasPlaying || wasPaused)) { + await Play(); + SeekTo(currentTime); + if (wasPaused) { + Pause(); + } + } + } + /// public void SeekBackward(TimeSpan duration) { var targetPosition = CurrentTime - duration; diff --git a/smoc/Ui/CommandLine.cs b/smoc/Ui/CommandLine.cs index c2e18f5..78a7df4 100644 --- a/smoc/Ui/CommandLine.cs +++ b/smoc/Ui/CommandLine.cs @@ -1,10 +1,14 @@ +using Terminal.Gui.App; using Smoc.Ui.Components; +using Smoc.Services; using Smoc.Ui.Models; -using Terminal.Gui.App; using Terminal.Gui.Configuration; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; +using System; +using System.Linq; +using Smoc.Services.Util; namespace Smoc.Ui; @@ -14,8 +18,14 @@ namespace Smoc.Ui; public sealed class CommandLine : View { private readonly CommandTextField _commandTextField; private readonly Label _errorLabel; + private readonly CommandService? _commandService; private object? _errorTimeoutTracker; + /// + /// Gets the current text entered in the command line text field. + /// + public string CommandText => _commandTextField.Text; + /// /// Occurs when the command input is cancelled (e.g. via Esc). /// @@ -24,7 +34,9 @@ public sealed class CommandLine : View { /// /// Initializes a new instance of the class. /// - public CommandLine() { + /// The command service to use for completions. + public CommandLine(CommandService? commandService = null) { + _commandService = commandService; Width = Dim.Fill(); Height = Dim.Absolute(1); CanFocus = true; @@ -59,14 +71,30 @@ public void DisplayError(string message) { _errorTimeoutTracker = App!.AddTimeout(TimeSpan.FromSeconds(5), () => { ClearError(); return false; }); } + /// protected override bool OnKeyDownNotHandled(Key key) { - if (key == Key.Tab) { + if (key == Key.Tab && _commandService != null) { + var text = _commandTextField.Text.TrimStart(':'); + var completions = _commandService.GetCompletions(text).ToList(); + if (completions.Count == 1) { + var completion = completions[0]; + var argCutoff = text.IndexOf('/'); + if (argCutoff > 0) { + _commandTextField.Text = $":{text[..(argCutoff + 1)]}{completion}"; + } else { + _commandTextField.Text = $":{completion}/"; + } + _commandTextField.InsertionPoint = _commandTextField.Text.Length; + } else if (completions.Count > 1) { + DisplayError($"Completions: {string.Join(", ", completions)}"); + } return true; } return base.OnKeyDownNotHandled(key); } + /// protected override void OnHasFocusChanged(bool newHasFocus, View? previousFocusedView, View? focusedView) { base.OnHasFocusChanged(newHasFocus, previousFocusedView, focusedView); if (newHasFocus) { @@ -102,4 +130,4 @@ private void OnTextChanging(object? sender, ResultEventArgs e) { RaiseAccepted(new CommandLineCommandContext(Command.Accept, new WeakReference(this), ctx?.Binding, ctx?.Routing ?? CommandRouting.Direct, _commandTextField.Text.TrimStart(':'))); return true; } -} +} \ No newline at end of file diff --git a/smoc/Ui/MainWindow.cs b/smoc/Ui/MainWindow.cs index e38a22a..5378904 100644 --- a/smoc/Ui/MainWindow.cs +++ b/smoc/Ui/MainWindow.cs @@ -1,7 +1,11 @@ +using Terminal.Gui.App; +using Sharpcaster.Models; using System.Reflection; using Smoc.Configuration; using Smoc.Services; using Smoc.Services.Audio.SoundFlow; +using Smoc.Services.Audio.Cast; +using Smoc.Services.Cast; using Smoc.Services.Streaming; using Smoc.Streaming; using Smoc.Ui.Models; @@ -22,6 +26,9 @@ public sealed class MainWindow : Runnable, IMainWindow { private readonly IPlaybackQueueService _playbackQueueService; private readonly CommandService _commandService; private readonly IPlaybackTrackingService _playbackTrackingService; + private readonly ICastDiscoveryService _castDiscoveryService; + private readonly IStreamingProxyService _streamingProxyService; + private readonly CancellationTokenSource _disposeCts = new(); private Mode? _currentMode; private View? _preCommandFocusedView; @@ -41,6 +48,19 @@ public MainWindow(IStreamingClient streamingClient) { streamingClient, TimeSpan.FromSeconds(ListenHistoryConfig.Defaults.MinimumPositionSeconds), ListenHistoryConfig.Defaults.MinimumFraction); + + _castDiscoveryService = new CastDiscoveryService(); + _streamingProxyService = new StreamingProxyService(); + _ = Task.Run(async () => { + try { + await _castDiscoveryService.StartDiscoveryAsync(_disposeCts.Token); + } catch (OperationCanceledException) { + // Normal cancellation + } catch (Exception ex) { + Logging.Error($"Initial Cast discovery error: {ex.Message}"); + } + }); + if (ListenHistoryConfig.Defaults.Enabled) { _playbackQueueService.PositionChanged += (_, position) => { if (_playbackQueueService.CurrentSong is { } song) { @@ -51,7 +71,7 @@ public MainWindow(IStreamingClient streamingClient) { _commandService = new CommandService(); _nowPlayingBar = new NowPlayingBar(this, _playbackQueueService, _commandService, streamingClient); - _commandLine = new CommandLine() { + _commandLine = new CommandLine(_commandService) { Y = Pos.AnchorEnd() }; _statusBar = new StatusBar(_playbackQueueService) { @@ -71,6 +91,67 @@ public MainWindow(IStreamingClient streamingClient) { } }); + _commandService.RegisterCompleter("output", (_, args) => { + var devices = new List { "local", "refresh" }; + devices.AddRange(_castDiscoveryService.DiscoveredDevices + .Select(d => d.Name) + .Where(name => !string.IsNullOrWhiteSpace(name))!); + return devices.Where(d => d.StartsWith(args, StringComparison.OrdinalIgnoreCase)); + }); + + _commandService.RegisterCommand("output", async (_, args) => { + try { + var parts = CommandService.GetArgs(args); + if (parts.Length == 0) { + await _castDiscoveryService.EnsureInitialDiscoveryCompletedAsync(_disposeCts.Token); + var devices = new List { "local" }; + devices.AddRange(_castDiscoveryService.DiscoveredDevices + .Select(d => d.Name) + .Where(name => !string.IsNullOrWhiteSpace(name))!); + _commandLine.DisplayError($"Available outputs: {string.Join(", ", devices)}"); + return; + } + + var target = parts[0]; + if (target.Equals("refresh", StringComparison.OrdinalIgnoreCase)) { + _commandLine.DisplayError("Scanning for Cast devices..."); + await _castDiscoveryService.ScanAsync(cancellationToken: _disposeCts.Token); + var refreshedDevices = new List { "local" }; + refreshedDevices.AddRange(_castDiscoveryService.DiscoveredDevices + .Select(d => d.Name) + .Where(name => !string.IsNullOrWhiteSpace(name))!); + _commandLine.DisplayError($"Available outputs: {string.Join(", ", refreshedDevices)}"); + return; + } + + if (target.Equals("local", StringComparison.OrdinalIgnoreCase)) { + await _playbackQueueService.SetAudioServiceAsync(new SoundFlowAudioService()); + _commandLine.DisplayError("Switched to local output"); + } else { + await _castDiscoveryService.EnsureInitialDiscoveryCompletedAsync(_disposeCts.Token); + + var device = FindDevice(target); + if (device == null) { + _commandLine.DisplayError($"Scanning for '{target}'..."); + await _castDiscoveryService.ScanAsync(TimeSpan.FromSeconds(2), _disposeCts.Token); + device = FindDevice(target); + } + + if (device == null) { + _commandLine.DisplayError($"Device not found: {target}"); + return; + } + + var castService = new CastAudioService(device, _streamingProxyService); + await castService.ConnectAsync(); + await _playbackQueueService.SetAudioServiceAsync(castService); + _commandLine.DisplayError($"Switched to {device.Name}"); + } + } catch (Exception ex) { + _commandLine.DisplayError($"Output switch error: {ex.Message}"); + } + }); + AddCommand(Command.HotKey, OnCommandLineHotKey); HotKeyBindings.Add(new Key(':'), Command.HotKey); HotKeyBindings.Add(new Key(':').WithShift, Command.HotKey); @@ -121,8 +202,27 @@ public void DisplayError(string message) { _commandLine.DisplayError(message); } + private ChromecastReceiver? FindDevice(string target) { + var devices = _castDiscoveryService.DiscoveredDevices; + var exactMatch = devices.FirstOrDefault(d => + (!string.IsNullOrEmpty(d.Name) && d.Name.Equals(target, StringComparison.OrdinalIgnoreCase)) || + (d.DeviceUri != null && d.DeviceUri.Host.Equals(target, StringComparison.OrdinalIgnoreCase))); + if (exactMatch != null) { + return exactMatch; + } + + return devices.FirstOrDefault(d => + !string.IsNullOrEmpty(d.Name) && d.Name.Contains(target, StringComparison.OrdinalIgnoreCase)); + } + protected override void Dispose(bool disposing) { + _disposeCts.Cancel(); + _disposeCts.Dispose(); _commandService.UnregisterCommand("q"); + _commandService.UnregisterCommand("output"); + _commandService.UnregisterCompleter("output"); + _castDiscoveryService.Dispose(); + _streamingProxyService.Dispose(); base.Dispose(disposing); } @@ -143,4 +243,4 @@ private static string GetModeDisplayName(Mode mode) { } throw new ArgumentException("Invalid mode"); } -} +} \ No newline at end of file diff --git a/smoc/smoc.csproj b/smoc/smoc.csproj index ad24d3f..1ace907 100644 --- a/smoc/smoc.csproj +++ b/smoc/smoc.csproj @@ -32,6 +32,7 @@ + - + \ No newline at end of file