Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0079335
feat(cast): add google cast support and :output command
oca-agent May 15, 2026
f6039a1
test(cast): add unit tests for cast services and command completion
oca-agent May 15, 2026
677fdea
chore(cast): upgrade SharpCaster to v3.0.0 and refactor for async APIs
oca-agent May 15, 2026
64a81b3
chore(cast): upgrade SharpCaster to v3.0.0 and refactor services for …
oca-agent May 15, 2026
27857ad
feat(cast): add logging for streaming proxy IP and port
oca-agent May 15, 2026
0a82548
feat(cast): log StreamingProxy IP and port
oca-agent May 15, 2026
7d885b4
fix(cast): improve local IP discovery for streaming proxy
oca-agent May 15, 2026
5501a4c
docs(cast): add XML doc comments and address PR review feedback
oca-agent May 16, 2026
19b1854
fix(cast): bind streaming proxy to local IP and support multi-request…
oca-agent May 22, 2026
83993d7
fix(cast): concurrent streams, range headers, udp ip probe, and error…
oca-agent Sep 12, 2026
8498b02
fix(cast): serialize CastPlaybackService command execution
oca-agent Sep 12, 2026
0c22e43
fix(cast): implement progress tracking and interpolation during playback
oca-agent Sep 12, 2026
363d310
fix(cast): optimize startup discovery scan and add on-demand fallback
oca-agent Sep 12, 2026
c69f823
test(cast): expand unit test coverage across Cast services and proxy
oca-agent Sep 12, 2026
2c1056f
fix(cast): fix automatic track progression when casting
oca-agent Sep 12, 2026
ddd9944
fix(cast): ensure connection and receiver application session before …
oca-agent Sep 12, 2026
c7fab61
refactor(cast): remove redundant ConfigureAwait(false) across Cast se…
oca-agent Sep 12, 2026
d515e4e
fix(cast): prevent capturing Terminal.Gui MainLoopSyncContext in asyn…
oca-agent Sep 12, 2026
27ab16c
refactor(cast): propagate CancellationToken across all async operatio…
oca-agent Sep 12, 2026
8203e76
fix(cast): address code review feedback across Cast and command services
oca-agent Sep 12, 2026
0b7d704
test: align simulated time advancement in VisualizerFps_DynamicChange…
oca-agent Sep 12, 2026
775a827
docs: document Google Cast support and :output command in README
oca-agent Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<device>` | 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

Expand Down
163 changes: 163 additions & 0 deletions smoc.Tests/Services/Audio/Cast/CastAudioServiceTest.cs
Original file line number Diff line number Diff line change
@@ -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<IStreamingProxyService> _mockProxyService;
private readonly Mock<IChromecastClient> _mockClient;
private readonly ChromecastReceiver _device;

public CastAudioServiceTest() {
_mockProxyService = new Mock<IStreamingProxyService>();
_mockClient = new Mock<IChromecastClient>();
_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<CastPlaybackService>(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<CancellationToken>()), 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<CancellationToken>()), 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<CancellationToken>()), Times.Once);
_mockClient.Verify(c => c.Dispose(), Times.Once);
}

[Fact]
public async Task OnSongEnded_AutomaticallyTransitionsToNextPreloadedTrack() {
var loadedMediaTitles = new List<string>();
_mockClient.Setup(c => c.LoadAsync(It.IsAny<Media>(), It.IsAny<CancellationToken>())).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<IStreamingClient>();
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<CancellationToken>()))
.ReturnsAsync(new SongStream(song1.Id, "mp3", new MemoryStream()));
mockStreaming.Setup(c => c.GetSongStreamAsync(song2.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SongStream(song2.Id, "mp3", new MemoryStream()));

_mockProxyService.Setup(p => p.StartProxy(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string>()))
.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);
}
}
Loading
Loading