remove redundant restAPI
Build Plugin / build (push) Successful in 2m46s
Release Plugin / build-and-release (push) Successful in 2m44s

playback is controlled by state machine
This commit is contained in:
2025-12-30 14:37:27 +01:00
parent 29cd6dfaeb
commit a199fe452c
9 changed files with 824 additions and 523 deletions
@@ -1,7 +1,7 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Models;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -23,12 +23,16 @@ public class LmsSessionController : ISessionController, IDisposable
private readonly SessionInfo _session;
private readonly ISessionManager _sessionManager;
private readonly ILibraryManager _libraryManager;
private readonly PlaybackStateMachine _stateMachine;
private readonly LmsStatusPoller _statusPoller;
private readonly CancellationTokenSource _cancellationTokenSource = new();
private Timer? _progressTimer;
private bool _disposed;
private BaseItem? _currentItem;
private Guid[] _playlist = [];
private int _playlistIndex;
private long _seekOffsetTicks; // Offset from transcoded stream start position
private PlaybackErrorInfo? _lastError;
/// <summary>
/// Initializes a new instance of the <see cref="LmsSessionController"/> class.
@@ -53,22 +57,38 @@ public class LmsSessionController : ISessionController, IDisposable
_session = session;
_sessionManager = sessionManager;
_libraryManager = libraryManager;
_stateMachine = new PlaybackStateMachine(logger);
_statusPoller = new LmsStatusPoller(lmsClient, logger);
}
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary>
/// Gets or sets the currently playing item ID.
/// </summary>
public Guid? CurrentItemId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether playback is currently active.
/// Gets a value indicating whether playback is currently active.
/// </summary>
public bool IsPlaying { get; set; }
public bool IsPlaying => _stateMachine.CurrentState == PlaybackState.Playing
|| _stateMachine.CurrentState == PlaybackState.Loading
|| _stateMachine.CurrentState == PlaybackState.Seeking;
/// <summary>
/// Gets or sets a value indicating whether playback is paused.
/// Gets a value indicating whether playback is paused.
/// </summary>
public bool IsPaused { get; set; }
public bool IsPaused => _stateMachine.CurrentState == PlaybackState.Paused;
/// <summary>
/// Gets the current playback state.
/// </summary>
public PlaybackState State => _stateMachine.CurrentState;
/// <summary>
/// Gets the last error that occurred during playback.
/// </summary>
public PlaybackErrorInfo? LastError => _lastError;
/// <inheritdoc />
public bool IsSessionActive => _player.IsConnected;
@@ -185,12 +205,51 @@ public class LmsSessionController : ISessionController, IDisposable
useDirectPath,
streamUrl);
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
// Transition to Loading state before sending command
_stateMachine.TryTransition(PlaybackState.Loading, "Starting playback");
// Send play command with retry logic
var playSuccess = await _statusPoller.ExecuteWithRetryAsync(
async () => await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false),
Config.MaxAutoRetries,
retry => _logger.LogInformation("Retrying play command (attempt {Retry})", retry),
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!playSuccess)
{
_lastError = new PlaybackErrorInfo
{
ErrorType = PlaybackErrorType.LmsError,
Message = "Failed to send play command to LMS after retries",
OccurredAt = DateTime.UtcNow,
RetryCount = Config.MaxAutoRetries
};
_stateMachine.TryTransition(PlaybackState.Error, "Play command failed");
return;
}
// Wait for LMS to confirm playback started
var loadingTimeout = TimeSpan.FromSeconds(Config.LoadingTimeoutSeconds);
var started = await _statusPoller.WaitForPlaybackStartAsync(
_player.MacAddress,
loadingTimeout,
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!started)
{
_lastError = new PlaybackErrorInfo
{
ErrorType = PlaybackErrorType.Timeout,
Message = $"LMS did not start playing within {loadingTimeout.TotalSeconds}s",
OccurredAt = DateTime.UtcNow
};
_stateMachine.TryTransition(PlaybackState.Error, "Loading timeout");
return;
}
// Track current playback state
CurrentItemId = itemId;
IsPlaying = true;
IsPaused = false;
_stateMachine.TryTransition(PlaybackState.Playing, "LMS confirmed playback");
// Track the seek offset so we report the correct position
// When using direct file paths, LMS handles seeking natively so no offset needed
@@ -252,7 +311,14 @@ public class LmsSessionController : ISessionController, IDisposable
private async Task ReportPlaybackProgressAsync()
{
if (!IsPlaying || !CurrentItemId.HasValue)
// Don't report during Loading, Seeking, or Error states
var currentState = _stateMachine.CurrentState;
if (currentState == PlaybackState.Loading
|| currentState == PlaybackState.Seeking
|| currentState == PlaybackState.Error
|| currentState == PlaybackState.Stopped
|| currentState == PlaybackState.Idle
|| !CurrentItemId.HasValue)
{
return;
}
@@ -269,19 +335,29 @@ public class LmsSessionController : ISessionController, IDisposable
// 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";
var lmsIsPaused = status.Mode == "pause";
// Update our local state from LMS
IsPaused = isPaused;
// Sync state machine with LMS state (handles external pause/play)
if (lmsIsPaused && currentState == PlaybackState.Playing)
{
_stateMachine.TryTransition(PlaybackState.Paused, "LMS reported pause");
}
else if (!lmsIsPaused && status.Mode == "play" && currentState == PlaybackState.Paused)
{
_stateMachine.TryTransition(PlaybackState.Playing, "LMS reported play");
}
// 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)
if (status.Mode == "stop" && currentState == PlaybackState.Playing)
{
// 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")
// Confirm stop with a quick poll instead of fixed delay
var stillStopped = await _statusPoller.WaitForModeAsync(
_player.MacAddress,
"stop",
TimeSpan.FromMilliseconds(500),
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!stillStopped)
{
_logger.LogDebug("LMS mode changed from stop, ignoring");
return;
@@ -311,7 +387,7 @@ public class LmsSessionController : ISessionController, IDisposable
{
ItemId = CurrentItemId.Value,
SessionId = _session.Id,
IsPaused = isPaused,
IsPaused = _stateMachine.CurrentState == PlaybackState.Paused,
PositionTicks = positionTicks,
PlayMethod = PlayMethod.DirectStream,
CanSeek = true,
@@ -347,8 +423,7 @@ public class LmsSessionController : ISessionController, IDisposable
_logger.LogInformation("Reporting playback stopped for item {ItemId}", CurrentItemId.Value);
await _sessionManager.OnPlaybackStopped(stopInfo).ConfigureAwait(false);
IsPlaying = false;
IsPaused = false;
_stateMachine.TryTransition(PlaybackState.Stopped, "Playback stopped");
CurrentItemId = null;
}
catch (Exception ex)
@@ -382,29 +457,28 @@ public class LmsSessionController : ISessionController, IDisposable
case PlaystateCommand.Pause:
var pauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("Pause command result: {Result}", pauseResult);
IsPaused = true;
_stateMachine.TryTransition(PlaybackState.Paused, "Pause command");
break;
case PlaystateCommand.Unpause:
var playResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("Unpause/Play command result: {Result}", playResult);
IsPaused = false;
_stateMachine.TryTransition(PlaybackState.Playing, "Unpause command");
break;
case PlaystateCommand.PlayPause:
// Toggle play/pause - check current state first
var currentState = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (currentState?.Mode == "play")
// Toggle play/pause based on current state
if (_stateMachine.CurrentState == PlaybackState.Playing)
{
var togglePauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("PlayPause toggle (pause) result: {Result}", togglePauseResult);
IsPaused = true;
_stateMachine.TryTransition(PlaybackState.Paused, "PlayPause toggle to pause");
}
else
{
var togglePlayResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("PlayPause toggle (play) result: {Result}", togglePlayResult);
IsPaused = false;
_stateMachine.TryTransition(PlaybackState.Playing, "PlayPause toggle to play");
}
break;
@@ -419,7 +493,10 @@ public class LmsSessionController : ISessionController, IDisposable
if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue)
{
var positionTicks = playstateRequest.SeekPositionTicks.Value;
var positionSeconds = positionTicks / TimeSpan.TicksPerSecond;
var positionSeconds = (double)(positionTicks / TimeSpan.TicksPerSecond);
// Transition to Seeking state (state machine remembers previous state)
_stateMachine.TryTransition(PlaybackState.Seeking, "Seek command");
// Check if we're using direct file path mode - if so, LMS can seek natively
if (CanSeekNatively())
@@ -427,13 +504,37 @@ public class LmsSessionController : ISessionController, IDisposable
// Use native LMS seeking - much smoother!
_logger.LogInformation("Seeking natively to {Seconds}s using LMS time command", positionSeconds);
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false);
// Wait for seek to complete
var seekTimeout = TimeSpan.FromSeconds(Config.SeekTimeoutSeconds);
var seekComplete = await _statusPoller.WaitForSeekCompleteAsync(
_player.MacAddress,
positionSeconds,
toleranceSeconds: 2.0,
seekTimeout,
_cancellationTokenSource.Token).ConfigureAwait(false);
// No seek offset needed - LMS handles position tracking natively
_seekOffsetTicks = 0;
// Restore previous state
var previousState = _stateMachine.StateBeforeSeek;
if (seekComplete)
{
_stateMachine.TryTransition(previousState, "Seek completed");
}
else
{
_logger.LogWarning("Seek may not have completed within timeout, restoring state anyway");
_stateMachine.TryTransition(previousState, "Seek timeout - restoring state");
}
}
else
{
// 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
// This is essentially a new playback, so transition to Loading
_stateMachine.TryTransition(PlaybackState.Loading, "HTTP stream seek - restarting");
var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks);
_logger.LogInformation(
"Seeking by restarting stream at position {Seconds}s: {Url}",
@@ -442,9 +543,30 @@ public class LmsSessionController : ISessionController, IDisposable
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
// Wait for playback to start
var loadingTimeout = TimeSpan.FromSeconds(Config.LoadingTimeoutSeconds);
var started = await _statusPoller.WaitForPlaybackStartAsync(
_player.MacAddress,
loadingTimeout,
_cancellationTokenSource.Token).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;
if (started)
{
_stateMachine.TryTransition(PlaybackState.Playing, "HTTP stream seek completed");
}
else
{
_lastError = new PlaybackErrorInfo
{
ErrorType = PlaybackErrorType.Timeout,
Message = "Stream restart after seek failed to start",
OccurredAt = DateTime.UtcNow
};
_stateMachine.TryTransition(PlaybackState.Error, "HTTP stream seek failed");
}
}
_logger.LogInformation("Seek offset is now {Ticks} ticks ({Seconds}s)", _seekOffsetTicks, _seekOffsetTicks / TimeSpan.TicksPerSecond);
@@ -686,7 +808,14 @@ public class LmsSessionController : ISessionController, IDisposable
if (disposing)
{
// Cancel any pending operations
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
StopProgressTimer();
// Reset state machine
_stateMachine.Reset();
}
_disposed = true;