remove redundant restAPI
playback is controlled by state machine
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user