diff --git a/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html b/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html index 12e3659..5746031 100644 --- a/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html +++ b/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html @@ -3,6 +3,64 @@ JellyLMS +
@@ -73,18 +131,6 @@
Automatically sync players when playing to multiple devices
- - -
-

LMS Players

-
-

Click "Refresh Players" to discover LMS players.

-
-
- -
@@ -96,15 +142,25 @@
-

Player Sync Management

-

Manage synchronized playback groups for multi-room audio.

-
- - Open Sync Manager - +

Player Sync

+

Select players to sync together for multi-room audio. Synced players play in perfect sync.

+ +
+
-
- Users can access this page directly at: /web/configurationpage?name=JellyLMS%20Sync + +
+

Loading players...

+
+ +
+ + +
@@ -118,45 +174,34 @@
-
- - diff --git a/Jellyfin.Plugin.JellyLMS/Jellyfin.Plugin.JellyLMS.csproj b/Jellyfin.Plugin.JellyLMS/Jellyfin.Plugin.JellyLMS.csproj index 7ab2623..30496d5 100644 --- a/Jellyfin.Plugin.JellyLMS/Jellyfin.Plugin.JellyLMS.csproj +++ b/Jellyfin.Plugin.JellyLMS/Jellyfin.Plugin.JellyLMS.csproj @@ -27,9 +27,7 @@ - - diff --git a/Jellyfin.Plugin.JellyLMS/Plugin.cs b/Jellyfin.Plugin.JellyLMS/Plugin.cs index b065b4d..36aa6a1 100644 --- a/Jellyfin.Plugin.JellyLMS/Plugin.cs +++ b/Jellyfin.Plugin.JellyLMS/Plugin.cs @@ -49,11 +49,6 @@ public class Plugin : BasePlugin, IHasWebPages { Name = Name, EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace) - }, - new PluginPageInfo - { - Name = "JellyLMS Sync", - EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.syncPage.html", GetType().Namespace) } ]; } diff --git a/Jellyfin.Plugin.JellyLMS/Services/LmsApiClient.cs b/Jellyfin.Plugin.JellyLMS/Services/LmsApiClient.cs index e0edb2e..c1fc9ab 100644 --- a/Jellyfin.Plugin.JellyLMS/Services/LmsApiClient.cs +++ b/Jellyfin.Plugin.JellyLMS/Services/LmsApiClient.cs @@ -226,8 +226,10 @@ public class LmsApiClient : ILmsApiClient, IDisposable { try { + _logger.LogInformation("LMS SeekAsync: player {Mac}, position {Seconds}s", playerMac, positionSeconds); await SendCommandAsync(playerMac, ["time", positionSeconds.ToString("F1", CultureInfo.InvariantCulture)]) .ConfigureAwait(false); + _logger.LogInformation("LMS SeekAsync: command sent successfully"); return true; } catch (Exception ex) diff --git a/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs b/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs index 7fdb8b0..9d8b31c 100644 --- a/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs +++ b/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs @@ -28,6 +28,7 @@ public class LmsSessionController : ISessionController, IDisposable private BaseItem? _currentItem; private Guid[] _playlist = []; private int _playlistIndex; + private long _seekOffsetTicks; // Offset from transcoded stream start position /// /// Initializes a new instance of the class. @@ -88,10 +89,20 @@ public class LmsSessionController : ISessionController, IDisposable CancellationToken cancellationToken) { _logger.LogInformation( - "LMS Session Controller received message {MessageType} for player {PlayerName} ({Mac})", + "LMS Session Controller received message {MessageType} for player {PlayerName} ({Mac}), data type: {DataType}", name, _player.Name, - _player.MacAddress); + _player.MacAddress, + data?.GetType().Name ?? "null"); + + // Log the data for debugging + if (data is PlaystateRequest psr) + { + _logger.LogInformation( + "PlaystateRequest: Command={Command}, SeekPositionTicks={Ticks}", + psr.Command, + psr.SeekPositionTicks); + } try { @@ -170,6 +181,7 @@ public class LmsSessionController : ISessionController, IDisposable CurrentItemId = itemId; IsPlaying = true; IsPaused = false; + _seekOffsetTicks = 0; // Reset seek offset when starting a new track // Look up the item for duration info _currentItem = _libraryManager.GetItemById(itemId); @@ -249,17 +261,45 @@ public class LmsSessionController : ISessionController, IDisposable return; } - var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond); + // LMS reports time relative to the current stream, but after seeking + // we're playing a transcoded stream that starts at the seek position. + // Add the seek offset to get the actual track position. + var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond) + _seekOffsetTicks; var isPaused = status.Mode == "pause"; // Update our local state from LMS IsPaused = isPaused; - // Check if playback has stopped on LMS side - if (status.Mode == "stop") + // Check if playback has stopped on LMS side (track ended) + // Only advance if we're not paused - LMS can briefly report "stop" during transitions + if (status.Mode == "stop" && !IsPaused) { - _logger.LogInformation("LMS playback stopped, reporting to Jellyfin"); - await ReportPlaybackStoppedAsync().ConfigureAwait(false); + // Double-check by getting status again after a brief delay to avoid false positives + await Task.Delay(500).ConfigureAwait(false); + var confirmStatus = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false); + if (confirmStatus?.Mode != "stop") + { + _logger.LogDebug("LMS mode changed from stop, ignoring"); + return; + } + + _logger.LogInformation("LMS playback stopped, checking if we should advance to next track"); + + // Check if there are more tracks in the playlist + if (_playlistIndex < _playlist.Length - 1) + { + _logger.LogInformation( + "Track ended, advancing to next track (index {Index}/{Total})", + _playlistIndex + 2, + _playlist.Length); + await PlayItemAtIndexAsync(_playlistIndex + 1).ConfigureAwait(false); + } + else + { + _logger.LogInformation("Playlist finished, reporting playback stopped"); + await ReportPlaybackStoppedAsync().ConfigureAwait(false); + } + return; } @@ -366,14 +406,34 @@ public class LmsSessionController : ISessionController, IDisposable break; case PlaystateCommand.Seek: - if (playstateRequest.SeekPositionTicks.HasValue) + _logger.LogInformation( + "Seek command received for player {PlayerName}, SeekPositionTicks: {Ticks}, CurrentItemId: {ItemId}, CurrentSeekOffset: {Offset}", + _player.Name, + playstateRequest.SeekPositionTicks, + CurrentItemId, + _seekOffsetTicks); + if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue) { - var positionSeconds = playstateRequest.SeekPositionTicks.Value / TimeSpan.TicksPerSecond; + var positionTicks = playstateRequest.SeekPositionTicks.Value; + + // For HTTP streams, LMS can't seek directly - we need to restart with startTimeTicks + // Build a new URL with the seek position and restart playback + var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks); _logger.LogInformation( - "Seeking player {PlayerName} to {Seconds} seconds", - _player.Name, - positionSeconds); - await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false); + "Seeking by restarting stream at position {Seconds}s: {Url}", + positionTicks / TimeSpan.TicksPerSecond, + streamUrl); + + await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false); + + // Track the seek offset so we report the correct position + // The transcoded stream starts at 0, but we need to report the actual track position + _seekOffsetTicks = positionTicks; + _logger.LogInformation("Set seek offset to {Ticks} ticks ({Seconds}s)", positionTicks, positionTicks / TimeSpan.TicksPerSecond); + } + else + { + _logger.LogWarning("Seek command received but SeekPositionTicks or CurrentItemId is null"); } break; @@ -473,14 +533,29 @@ public class LmsSessionController : ISessionController, IDisposable } private string BuildStreamUrl(Guid itemId) + { + return BuildStreamUrlWithPosition(itemId, 0); + } + + private string BuildStreamUrlWithPosition(Guid itemId, long startPositionTicks) { var config = Plugin.Instance?.Configuration; var jellyfinUrl = config?.JellyfinServerUrl?.TrimEnd('/') ?? "http://localhost:8096"; var apiKey = config?.JellyfinApiKey ?? string.Empty; - // Build audio stream URL that LMS can fetch - // Use direct stream endpoint - simpler and more compatible - var url = $"{jellyfinUrl}/Audio/{itemId}/stream?static=true"; + string url; + + if (startPositionTicks > 0) + { + // For seeking, we need to use transcoding (static=true doesn't support startTimeTicks) + // Use MP3 transcoding with the start position + url = $"{jellyfinUrl}/Audio/{itemId}/stream.mp3?audioCodec=mp3&audioBitRate=320000&startTimeTicks={startPositionTicks}"; + } + else + { + // For normal playback from start, use static streaming (better quality, no transcoding) + url = $"{jellyfinUrl}/Audio/{itemId}/stream.mp3?static=true"; + } if (!string.IsNullOrEmpty(apiKey)) { diff --git a/README.md b/README.md index 80ab6a2..dd97060 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,28 @@ JellyLMS enables Jellyfin to stream audio to LMS, which acts as a multi-room spe - **Playback Control**: Play, pause, stop, seek, and volume control forwarded to LMS - **Stream Bridging**: Generates audio stream URLs from Jellyfin for LMS to consume +## Screenshots + +### Cast to LMS Players +Select any LMS player directly from Jellyfin's "Play On" menu: + +![Play On Menu](images/usage.png) + +### Plugin Configuration +Configure LMS and Jellyfin server connections: + +![Configuration Page](images/settings.png) + +### Player Discovery +View discovered LMS players with their status and volume levels: + +![Player List](images/settings%202.png) + +### Sync Group Management +Create and manage synchronized playback groups for multi-room audio: + +![Sync Manager](images/sync.png) + ## Requirements - Jellyfin Server 10.10.0 or later diff --git a/images/settings 2.png b/images/settings 2.png new file mode 100644 index 0000000..4c4ff76 Binary files /dev/null and b/images/settings 2.png differ diff --git a/images/settings.png b/images/settings.png new file mode 100644 index 0000000..d5e9ccd Binary files /dev/null and b/images/settings.png differ diff --git a/images/sync.png b/images/sync.png new file mode 100644 index 0000000..7bb72b3 Binary files /dev/null and b/images/sync.png differ diff --git a/images/usage.png b/images/usage.png new file mode 100644 index 0000000..ae5cfb0 Binary files /dev/null and b/images/usage.png differ