Files
plezy/lib/services/music/music_playback_service_impl.dart
T
edde746 422db75b5b feat(music): mpv audio playback engine with gapless queue service
Audio-only mpv core on every platform (dedicated
com.plezy/mpv_audio_player channels): parameterized android/windows/
linux mpv plugins and a new apple MpvAudioPlayerCore, all skipping
video/window paths (vid=no, audio-display=no, gapless-audio=weak).
MusicPlaybackService drives an in-memory queue with shuffle/repeat,
file-loaded-event gapless arming (property edges coalesce and the
android bridge drops them), per-track progress reporting, OS media
controls, audio focus, sleep timer, and error auto-skip.
PlaybackCoordinator enforces one live native player: starting video
disposes the audio core first.
2026-07-05 19:26:28 +02:00

974 lines
33 KiB
Dart

import 'dart:async';
import 'package:os_media_controls/os_media_controls.dart';
import '../../database/app_database.dart';
import '../../media/ids.dart';
import '../../media/lyrics.dart';
import '../../media/media_item.dart';
import '../../media/media_server_client.dart';
import '../../mpv/models.dart';
import '../../mpv/player/player.dart';
import '../../utils/app_logger.dart';
import '../media_controls_manager.dart';
import '../multi_server_manager.dart';
import '../offline_watch_sync_service.dart';
import '../playback_coordinator.dart';
import '../playback_progress_tracker.dart';
import 'music_playback_service.dart';
import 'music_queue_controller.dart';
import 'music_source_resolver.dart';
/// A gapless-armed next track: what [Player.setNext] was fed, so the
/// trackTransition event can be mapped back to a queue entry and its
/// already-resolved source reused without a second server round-trip.
class _ArmedTrack {
final MediaItem track;
final MusicSource source;
const _ArmedTrack({required this.track, required this.source});
}
/// Real music playback engine: owns the audio [Player], the queue
/// (via [MusicQueueController]), gapless arming, per-track server progress
/// reporting, and the OS media session.
///
/// ### Advancement paths
/// * **Gapless (normal):** after a track starts, the next queue entry is
/// resolved and armed via [Player.setNext]. When the backend auto-advances
/// it emits `trackTransition(uri)` — treated as the authoritative advance:
/// the finished track's tracker reports `stopped` at its full duration,
/// the cursor moves to the armed entry, services rebind, and the new next
/// is armed.
/// * **Completed fallback:** `completed` with nothing armed means either the
/// queue truly ended (repeat off, last track) — the session parks
/// `paused` at the end, keeping [currentTrack] so the mini-player stays —
/// or arming failed, in which case the next track is opened explicitly.
/// * **Manual:** next/previous/jumpTo/removeAt-current finalize the current
/// tracker at its *current* position and open the target directly.
///
/// ### Errors
/// Player/resolver failures surface on [errors] (for a snackbar) and
/// auto-skip to the next track; three consecutive failures without playback
/// progress stop the session with [MusicPlaybackStatus.error].
class MusicPlaybackServiceImpl extends MusicPlaybackService {
MusicPlaybackServiceImpl({
required MultiServerManager serverManager,
AppDatabase? database,
this._offlineWatchService,
MusicSourceResolver? resolver,
this._audioPlayerFactory = Player.audio,
this._mediaControlsFactory = MediaControlsManager.new,
this._completedConfirmDelay = const Duration(milliseconds: 400),
PlaybackCoordinator? coordinator,
}) : assert(resolver != null || database != null, 'database is required to build the default resolver'),
_serverManager = serverManager,
_resolver = resolver ?? ServerMusicSourceResolver(serverManager: serverManager, database: database!),
_coordinator = coordinator ?? PlaybackCoordinator.instance {
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim);
}
static const _previousRestartThreshold = Duration(seconds: 3);
static const _maxConsecutiveFailures = 3;
/// How long a completed (eof-reached) signal must stay uncontradicted
/// before it is treated as a genuine queue end — long enough for the
/// boundary pulse's own eof-reached=false / transition to arrive, short
/// enough to be imperceptible at a real queue end (see [_onCompleted]).
/// Injectable so tests can collapse the confirmation window.
final Duration _completedConfirmDelay;
final MultiServerManager _serverManager;
final OfflineWatchSyncService? _offlineWatchService;
final MusicSourceResolver _resolver;
final Player Function() _audioPlayerFactory;
final MediaControlsManager Function() _mediaControlsFactory;
final PlaybackCoordinator _coordinator;
final MusicQueueController _queue = MusicQueueController();
Player? _player;
final List<StreamSubscription<Object?>> _playerSubs = [];
MediaControlsManager? _mediaControls;
StreamSubscription<MediaControlEvent>? _controlEventsSub;
MusicPlaybackStatus _status = MusicPlaybackStatus.idle;
MediaItem? _currentTrack;
MusicSource? _currentSource;
MusicPlayContext? _playContext;
PlaybackProgressTracker? _tracker;
_ArmedTrack? _armed;
Timer? _completedConfirmTimer;
/// Bumped on every open/advance/stop so stale async continuations
/// (resolves, opens, arms) drop out instead of acting on the new state.
int _generation = 0;
int _consecutiveFailures = 0;
bool _resumeAfterInterruption = false;
bool _disposed = false;
Timer? _sleepTimer;
DateTime? _sleepTimerEndsAt;
bool _sleepTimerEndOfTrack = false;
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast();
final StreamController<Object> _errorsController = StreamController<Object>.broadcast();
// ---------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------
@override
bool get isAvailable => true;
@override
MediaItem? get currentTrack => _currentTrack;
@override
MusicPlaybackStatus get status => _status;
@override
Duration? get duration {
if (_currentTrack == null) return null;
final playerDuration = _player?.state.duration ?? Duration.zero;
if (playerDuration > Duration.zero) return playerDuration;
final ms = _currentTrack?.durationMs;
return ms != null ? Duration(milliseconds: ms) : null;
}
@override
Duration get position => _player?.currentPosition ?? Duration.zero;
@override
Stream<Duration> get positionStream => _positionController.stream;
@override
List<MediaItem> get queue => _queue.queue;
@override
int get currentIndex => _queue.cursor;
@override
MusicPlayContext? get playContext => _playContext;
@override
bool get shuffled => _queue.shuffled;
@override
MusicRepeatMode get repeatMode => _queue.repeatMode;
@override
Stream<Object> get errors => _errorsController.stream;
@override
bool get sleepTimerActive => _sleepTimer != null || _sleepTimerEndOfTrack;
@override
DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt;
@override
bool get sleepTimerEndOfTrack => _sleepTimerEndOfTrack;
// ---------------------------------------------------------------------
// Session start
// ---------------------------------------------------------------------
@override
Future<void> playFromList({
required List<MediaItem> tracks,
MediaItem? startTrack,
required MusicPlayContext playContext,
bool shuffle = false,
}) {
return _startQueue(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle);
}
@override
Future<void> playInstantMix(MediaItem seed) async {
final client = _clientFor(seed);
if (client == null) {
_errorsController.add(StateError('No server available for instant mix'));
return;
}
List<MediaItem> tracks;
try {
tracks = await client.fetchInstantMix(seed.id);
} catch (e, st) {
appLogger.w('Instant mix fetch failed for ${seed.id}', error: e, stackTrace: st);
_errorsController.add(e);
return;
}
if (_disposed || tracks.isEmpty) return;
await _startQueue(
tracks: tracks,
playContext: MusicPlayContext(title: seed.displayTitle, kind: MusicPlayContextKind.mix),
);
}
Future<void> _startQueue({
required List<MediaItem> tracks,
MediaItem? startTrack,
required MusicPlayContext playContext,
bool shuffle = false,
bool autoplay = true,
}) async {
if (tracks.isEmpty || _disposed) return;
final generation = ++_generation;
_finalizeCurrentTrack();
var startIndex = 0;
if (startTrack != null) {
startIndex = tracks.indexWhere((t) => t.globalKey == startTrack.globalKey);
if (startIndex < 0) startIndex = 0;
}
_queue.load(tracks, startIndex: startIndex, shuffle: shuffle);
_playContext = playContext;
_consecutiveFailures = 0;
await _openCurrent(generation, play: autoplay);
}
// ---------------------------------------------------------------------
// Opening / advancing
// ---------------------------------------------------------------------
/// Resolve and open the queue's current track. All failure handling funnels
/// through [_handlePlaybackFailure].
Future<void> _openCurrent(int generation, {bool play = true}) async {
final track = _queue.current;
if (track == null) return;
_currentTrack = track;
_currentSource = null;
_armed = null;
_setStatus(MusicPlaybackStatus.loading, forceNotify: true);
await _coordinator.claimMusic();
if (generation != _generation) return;
final player = _ensurePlayer();
_ensureMediaControls();
// Clear any native arm left over from the previous item before the open
// replaces it, so a stray transition can't fire mid-switch.
try {
await player.setNext(null);
} catch (e) {
appLogger.d('setNext(null) before open failed', error: e);
}
MusicSource source;
try {
source = await _resolver.resolve(track);
} catch (e, st) {
appLogger.w('Music source resolve failed for ${track.id}', error: e, stackTrace: st);
if (generation == _generation) _handlePlaybackFailure(e);
return;
}
if (generation != _generation || _player != player) return;
_currentSource = source;
// Claim audio focus before audio starts so other media apps pause (mpv
// has no built-in focus handling; harmless no-op off Android). Result is
// ignored — mirrors the video screen, playback proceeds either way.
try {
await player.requestAudioFocus();
} catch (e) {
appLogger.d('Audio focus request failed', error: e);
}
if (generation != _generation || _player != player) return;
try {
await player.open(Media(source.url, headers: source.headers), play: play);
} catch (e, st) {
appLogger.w('Music open failed for ${track.id}', error: e, stackTrace: st);
if (generation == _generation) _handlePlaybackFailure(e);
return;
}
if (generation != _generation || _player != player) return;
_setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
_bindTrackServices(track, source);
unawaited(_armNext(generation));
}
/// Manual advance: finalize the current tracker at its current position and
/// open the queue entry at [cursor].
Future<void> _advanceTo(int cursor, {bool play = true}) async {
final generation = ++_generation;
_finalizeCurrentTrack();
_queue.jumpTo(cursor);
await _openCurrent(generation, play: play);
}
/// Arm (or clear) what the backend should auto-advance into. Skips the
/// resolve round-trip when the desired target is already armed; repeat-one
/// reuses the current track's resolved source.
Future<void> _armNext(int generation) async {
final player = _player;
if (player == null || generation != _generation) return;
final targetCursor = _sleepTimerEndOfTrack ? null : _queue.nextIndex();
final target = targetCursor == null ? null : _queue.trackAt(targetCursor);
if (target == null) {
if (_armed == null) return;
appLogger.d('Music: clearing arm (queue end / end-of-track sleep)');
_armed = null;
await _trySetNext(player, null);
return;
}
if (_armed?.track.globalKey == target.globalKey) return;
_armed = null;
await _trySetNext(player, null);
if (generation != _generation || _player != player) return;
MusicSource source;
if (targetCursor == _queue.cursor && _currentSource != null) {
// Repeat-one: the same file plays again — reuse the resolved source.
source = _currentSource!;
} else {
try {
source = await _resolver.resolve(target);
} catch (e, st) {
// Fail soft: with nothing armed, the completed event falls back to
// an explicit open of the next track (which retries the resolve).
appLogger.w('Gapless arm resolve failed for ${target.id}', error: e, stackTrace: st);
return;
}
}
if (generation != _generation || _player != player) return;
_armed = _ArmedTrack(track: target, source: source);
appLogger.d('Music: arming cursor $targetCursor "${target.title}"');
final ok = await _trySetNext(player, Media(source.url, headers: source.headers));
if (!ok && generation == _generation && _player == player) {
// Nothing is armed natively; clear the record so the confirmed
// completed fallback can advance explicitly instead of waiting for a
// transition that can never come.
_armed = null;
}
}
Future<bool> _trySetNext(Player player, Media? media) async {
try {
await player.setNext(media);
return true;
} catch (e) {
appLogger.w('setNext failed', error: e);
return false;
}
}
/// Re-arm only when queue/mode changes altered what plays next — queue
/// edits that keep the same next track cost no server round-trip.
void _rearmIfNeeded() {
if (_player == null || _currentTrack == null) return;
final targetCursor = _sleepTimerEndOfTrack ? null : _queue.nextIndex();
final target = targetCursor == null ? null : _queue.trackAt(targetCursor);
if (target == null && _armed == null) return;
if (target != null && _armed?.track.globalKey == target.globalKey) return;
unawaited(_armNext(_generation));
}
// ---------------------------------------------------------------------
// Player events
// ---------------------------------------------------------------------
Player _ensurePlayer() {
final existing = _player;
if (existing != null && !existing.disposed) return existing;
final player = _audioPlayerFactory();
_player = player;
_wirePlayerStreams(player);
return player;
}
void _wirePlayerStreams(Player player) {
for (final sub in _playerSubs) {
sub.cancel();
}
_playerSubs
..clear()
..add(player.streams.position.listen(_onPosition))
..add(player.streams.playing.listen(_onPlayingChanged))
..add(player.streams.trackTransition.listen(_onTrackTransition))
..add(player.streams.completed.listen(_onCompleted))
..add(player.streams.error.listen(_onPlayerError));
}
void _onPosition(Duration position) {
_positionController.add(position);
// Real playback progress proves the pipeline recovered — reset the
// consecutive-failure strike counter.
if (_consecutiveFailures != 0 && position > Duration.zero && _status == MusicPlaybackStatus.playing) {
_consecutiveFailures = 0;
}
final player = _player;
if (player != null) {
_mediaControls?.updatePlaybackState(isPlaying: player.state.isActive, position: position, speed: 1.0);
}
}
void _onPlayingChanged(bool isPlaying) {
if (_status == MusicPlaybackStatus.playing || _status == MusicPlaybackStatus.paused) {
_setStatus(isPlaying ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
unawaited(_tracker?.sendProgress(isPlaying ? 'playing' : 'paused'));
}
final player = _player;
if (player != null) {
_mediaControls?.updatePlaybackState(
isPlaying: player.state.isActive,
position: player.currentPosition,
speed: 1.0,
force: true,
);
}
}
/// The backend auto-advanced into the pre-armed item: authoritative
/// track change.
void _onTrackTransition(String uri) {
final armed = _armed;
if (armed == null || armed.source.url != uri) {
appLogger.w('Unexpected track transition to $uri (armed: ${armed?.source.url})');
return;
}
_armed = null;
final generation = ++_generation;
// The finished track played out fully — report stopped at its duration.
final finishedMs = _currentTrack?.durationMs;
_finalizeCurrentTrack(positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : null);
// Move the cursor to the armed entry: the expected natural-next when it
// still matches, otherwise wherever the armed track now sits.
final expected = _queue.nextIndex();
if (expected != null && _queue.trackAt(expected)?.globalKey == armed.track.globalKey) {
_queue.jumpTo(expected);
} else {
final index = _queue.queue.indexWhere((t) => t.globalKey == armed.track.globalKey);
if (index >= 0) _queue.jumpTo(index);
}
_currentTrack = _queue.current ?? armed.track;
_currentSource = armed.source;
_consecutiveFailures = 0;
appLogger.d('Music: transition received "${armed.track.title}" → cursor ${_queue.cursor}');
_setStatus(MusicPlaybackStatus.playing, forceNotify: true);
_bindTrackServices(_currentTrack!, armed.source);
unawaited(_armNext(generation));
}
/// Completed (eof-reached) is NOT a last-entry-only signal: mpv pulses it
/// at every gapless boundary (the audio of the finished entry drains
/// before the armed entry starts), and its delivery order against the
/// trackTransition event is not guaranteed. A boundary pulse that lands
/// after the transition already cleared [_armed] (re-arm still resolving)
/// looks exactly like "queue advanced with nothing armed" — acting on it
/// immediately double-advanced the queue (skipped a track, cut off the
/// just-started file; live-captured on Android). So never act on the raw
/// pulse: confirm it is stable first. A boundary pulse is followed by
/// eof-reached=false / a transition within milliseconds (which resets
/// `state.completed` and bumps [_generation]); at a genuine queue end,
/// sleep-at-end-of-track, or failed arm it stays true, and the confirmed
/// handler advances explicitly or parks.
void _onCompleted(bool done) {
if (!done || _currentTrack == null || _status == MusicPlaybackStatus.idle) return;
appLogger.d('Music: completed received (armed=${_armed != null}, cursor ${_queue.cursor})');
if (_armed != null) return; // The backend advances; trackTransition handles it.
final generation = _generation;
_completedConfirmTimer?.cancel();
_completedConfirmTimer = Timer(_completedConfirmDelay, () {
_completedConfirmTimer = null;
if (_disposed || generation != _generation || _armed != null) return;
if (_player?.state.completed != true) return; // stale boundary pulse
appLogger.d('Music: completed confirmed (cursor ${_queue.cursor})');
_handleQueueCompleted();
});
}
/// Confirmed end of the current file with nothing armed: queue end,
/// sleep-at-end-of-track, or a failed arm (fall back to an explicit open).
void _handleQueueCompleted() {
if (_sleepTimerEndOfTrack) {
_sleepTimerEndOfTrack = false;
_parkAtEnd();
return;
}
final nextCursor = _queue.nextIndex();
if (nextCursor != null) {
unawaited(_advanceTo(nextCursor));
return;
}
_parkAtEnd();
}
/// Queue played out: report the final track stopped at its duration and
/// park paused at the end. [currentTrack] stays set so the mini-player
/// remains; pressing play restarts the current track from the top.
void _parkAtEnd() {
_generation++;
final finishedMs = _currentTrack?.durationMs;
_finalizeCurrentTrack(positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : null);
_setStatus(MusicPlaybackStatus.paused, forceNotify: true);
final player = _player;
if (player != null) {
_mediaControls?.updatePlaybackState(isPlaying: false, position: player.currentPosition, speed: 1.0, force: true);
}
}
void _onPlayerError(PlayerError error) {
if (_status == MusicPlaybackStatus.idle || _status == MusicPlaybackStatus.error) return;
appLogger.w('Music player error: $error');
_handlePlaybackFailure(error);
}
/// Shared recovery for resolve/open/player errors: surface, then skip to
/// the next track; three consecutive strikes stop the session as failed.
void _handlePlaybackFailure(Object error) {
_errorsController.add(error);
_consecutiveFailures++;
if (_consecutiveFailures >= _maxConsecutiveFailures) {
unawaited(_stopSession(endStatus: MusicPlaybackStatus.error));
return;
}
final nextCursor = _queue.nextIndex(manual: true);
if (nextCursor == null) {
unawaited(_stopSession(endStatus: MusicPlaybackStatus.error));
return;
}
unawaited(_advanceTo(nextCursor));
}
// ---------------------------------------------------------------------
// Per-track services (progress reporting + OS media controls)
// ---------------------------------------------------------------------
/// (Re)bind the per-track progress tracker and media-session metadata —
/// the music mirror of the video screen's `_wirePerItemPlaybackServices`.
/// The previous track must already be finalized.
void _bindTrackServices(MediaItem track, MusicSource source) {
_tracker?.dispose();
_tracker = null;
final player = _player;
if (player == null) return;
final client = source.reportingClient;
if (client != null) {
_tracker = PlaybackProgressTracker(
client: client,
metadata: track,
player: player,
offlineWatchService: _offlineWatchService,
// Local files keep reporting online but queue locally when the
// server rejects the report — same policy as downloaded video.
queueOnOnlineFailure: source.isOffline && _offlineWatchService != null,
playMethod: source.playMethod ?? 'DirectPlay',
playSessionId: source.playSessionId,
mediaInfo: source.mediaInfo,
)..startTracking();
} else if (source.isOffline && _offlineWatchService != null) {
_tracker = PlaybackProgressTracker(
client: null,
metadata: track,
player: player,
isOffline: true,
offlineWatchService: _offlineWatchService,
)..startTracking();
}
final controls = _mediaControls;
if (controls != null) {
unawaited(
controls.updateMetadata(
metadata: track,
client: client ?? _clientFor(track),
duration: track.durationMs != null ? Duration(milliseconds: track.durationMs!) : null,
),
);
_syncControlsAvailability();
}
}
/// Stop tracking and fire the final `stopped` report for the current
/// track (fire-and-forget; report sessions are per track so the next
/// track's `started` can overlap safely).
void _finalizeCurrentTrack({Duration? positionOverride}) {
final tracker = _tracker;
_tracker = null;
if (tracker == null) return;
tracker.stopTracking();
unawaited(
tracker.sendStoppedProgressOnce(positionOverride: positionOverride).catchError((Object e) {
appLogger.d('Final music progress report failed', error: e);
}),
);
}
void _ensureMediaControls() {
if (_mediaControls != null) return;
final controls = _mediaControlsFactory();
_mediaControls = controls;
_controlEventsSub = controls.controlEvents.listen(_onControlEvent);
}
void _syncControlsAvailability() {
unawaited(
_mediaControls?.setControlsEnabled(
canGoNext: _queue.nextIndex(manual: true) != null,
// Previous always restarts the track even at queue head.
canGoPrevious: true,
canSeek: true,
),
);
}
void _onControlEvent(MediaControlEvent event) {
if (_disposed || _currentTrack == null) return;
if (event is PlayEvent) {
unawaited(play());
} else if (event is PauseEvent) {
unawaited(pause());
} else if (event is TogglePlayPauseEvent) {
unawaited(togglePlayPause());
} else if (event is NextTrackEvent) {
unawaited(next());
} else if (event is PreviousTrackEvent) {
unawaited(previous());
} else if (event is SeekEvent) {
unawaited(seek(event.position));
} else if (event is AudioInterruptionBeganEvent || event is AudioRouteOldDeviceUnavailableEvent) {
// Remember whether we were playing so interruption-end/route-return
// can resume. Unlike video, music resumes even while backgrounded —
// background audio is the product.
_resumeAfterInterruption = _player?.state.isActive ?? false;
unawaited(pause());
} else if (event is AudioInterruptionEndedEvent) {
if (event.shouldResume && _resumeAfterInterruption) {
_resumeAfterInterruption = false;
unawaited(play());
} else {
_resumeAfterInterruption = false;
}
} else if (event is AudioRouteNewDeviceAvailableEvent) {
if (_resumeAfterInterruption) {
_resumeAfterInterruption = false;
unawaited(play());
}
}
}
// ---------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------
@override
Future<void> play() async {
final player = _player;
if (player == null || _currentTrack == null) return;
if (player.state.completed) {
// Parked at queue end: restart the current track.
await player.seek(Duration.zero);
unawaited(_armNext(_generation));
}
await player.play();
_setStatus(MusicPlaybackStatus.playing);
}
@override
Future<void> pause() async {
final player = _player;
if (player == null) return;
await player.pause();
_setStatus(MusicPlaybackStatus.paused);
}
@override
Future<void> togglePlayPause() {
final player = _player;
if (player == null) return Future.value();
return player.state.isActive ? pause() : play();
}
@override
Future<void> next() async {
final nextCursor = _queue.nextIndex(manual: true);
if (nextCursor == null) return;
await _advanceTo(nextCursor);
}
@override
Future<void> previous() async {
final player = _player;
if (player == null) return;
if (player.currentPosition > _previousRestartThreshold) {
await player.seek(Duration.zero);
return;
}
final prevCursor = _queue.previousIndex();
if (prevCursor == null) {
await player.seek(Duration.zero);
return;
}
await _advanceTo(prevCursor);
}
@override
Future<void> seek(Duration position) async {
await _player?.seek(position);
}
@override
Future<void> jumpTo(int index) async {
if (index < 0 || index >= _queue.length || index == _queue.cursor) return;
await _advanceTo(index);
}
// ---------------------------------------------------------------------
// Queue / mode edits
// ---------------------------------------------------------------------
@override
void setRepeatMode(MusicRepeatMode mode) {
if (_queue.repeatMode == mode) return;
_queue.repeatMode = mode;
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void toggleShuffle() {
if (_queue.isEmpty) return;
_queue.toggleShuffle();
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void removeAt(int index) {
if (index < 0 || index >= _queue.length) return;
final wasCurrent = _queue.removeAt(index);
if (wasCurrent) {
if (_queue.isEmpty) {
unawaited(stop());
return;
}
// The cursor already points at what used to be next — open it.
unawaited(_advanceTo(_queue.cursor));
return;
}
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void reorder(int from, int to) {
if (from == to) return;
_queue.move(from, to);
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void addNext(List<MediaItem> tracks) => _enqueue(tracks, next: true);
@override
void addToEnd(List<MediaItem> tracks) => _enqueue(tracks, next: false);
/// Queue edits while idle start a session parked on the first added track
/// (mini-player appears paused) instead of silently dropping the action
/// or surprising the user with audio.
void _enqueue(List<MediaItem> tracks, {required bool next}) {
if (tracks.isEmpty) return;
if (_queue.isEmpty || _currentTrack == null) {
final first = tracks.first;
unawaited(
_startQueue(
tracks: tracks,
playContext: MusicPlayContext(
title: first.albumTitle ?? first.title ?? '',
kind: MusicPlayContextKind.tracks,
),
autoplay: false,
),
);
return;
}
if (next) {
_queue.addNext(tracks);
} else {
_queue.addToEnd(tracks);
}
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void clearUpcoming() {
if (_queue.isEmpty) return;
_queue.clearUpcoming();
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
// ---------------------------------------------------------------------
// Sleep timer
// ---------------------------------------------------------------------
@override
void setSleepTimer(Duration? duration, {bool endOfTrack = false}) {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerEndsAt = null;
final hadEndOfTrack = _sleepTimerEndOfTrack;
_sleepTimerEndOfTrack = endOfTrack;
if (!endOfTrack && duration != null) {
_sleepTimerEndsAt = DateTime.now().add(duration);
_sleepTimer = Timer(duration, _onSleepTimerFired);
}
// End-of-track mode suppresses gapless arming (and leaving it restores
// the arm), so the track genuinely completes instead of transitioning.
if (hadEndOfTrack != _sleepTimerEndOfTrack) {
unawaited(_armNext(_generation));
}
notifyListeners();
}
void _onSleepTimerFired() {
_sleepTimer = null;
_sleepTimerEndsAt = null;
unawaited(pause());
notifyListeners();
}
void _cancelSleepTimer() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerEndsAt = null;
_sleepTimerEndOfTrack = false;
}
// ---------------------------------------------------------------------
// Stop / teardown
// ---------------------------------------------------------------------
@override
Future<void> stop() => _stopSession(endStatus: MusicPlaybackStatus.idle);
/// The coordinator's video claim uses the exact same full-stop path, so
/// the audio core is guaranteed disposed when it resolves.
Future<void> _stopForVideoClaim() => _stopSession(endStatus: MusicPlaybackStatus.idle);
Future<void> _stopSession({required MusicPlaybackStatus endStatus}) async {
_generation++;
_completedConfirmTimer?.cancel();
_completedConfirmTimer = null;
_cancelSleepTimer();
_finalizeCurrentTrack();
_queue.clear();
_currentTrack = null;
_currentSource = null;
_armed = null;
_playContext = null;
_resumeAfterInterruption = false;
final player = _player;
_player = null;
for (final sub in _playerSubs) {
unawaited(sub.cancel());
}
_playerSubs.clear();
if (player != null && !player.disposed) {
try {
await player.stop();
} catch (e) {
appLogger.d('Audio player stop failed during session teardown', error: e);
}
try {
await player.abandonAudioFocus();
} catch (e) {
appLogger.d('Audio focus abandon failed during session teardown', error: e);
}
try {
await player.dispose();
} catch (e) {
appLogger.w('Audio player dispose failed during session teardown', error: e);
}
}
unawaited(_controlEventsSub?.cancel());
_controlEventsSub = null;
final controls = _mediaControls;
_mediaControls = null;
if (controls != null) {
unawaited(controls.clear());
controls.dispose();
}
_setStatus(endStatus, forceNotify: true);
}
@override
Future<Lyrics?> fetchLyrics(MediaItem track) async {
final client = _clientFor(track);
if (client == null) return null;
return client.fetchLyrics(track);
}
MediaServerClient? _clientFor(MediaItem item) {
final serverId = serverIdOrNull(item.serverId);
if (serverId == null) return null;
return _serverManager.getClient(serverId);
}
void _setStatus(MusicPlaybackStatus status, {bool forceNotify = false}) {
if (_disposed) return;
if (_status == status && !forceNotify) return;
_status = status;
notifyListeners();
}
@override
void dispose() {
if (_disposed) return;
_disposed = true;
_coordinator.unregisterMusicSession(_stopForVideoClaim);
_completedConfirmTimer?.cancel();
_completedConfirmTimer = null;
_cancelSleepTimer();
_finalizeCurrentTrack();
for (final sub in _playerSubs) {
unawaited(sub.cancel());
}
_playerSubs.clear();
unawaited(_controlEventsSub?.cancel());
_controlEventsSub = null;
final player = _player;
_player = null;
if (player != null && !player.disposed) {
unawaited(
player.abandonAudioFocus().catchError((Object e) {
appLogger.d('Audio focus abandon failed during dispose', error: e);
}),
);
unawaited(player.dispose());
}
final controls = _mediaControls;
_mediaControls = null;
if (controls != null) {
unawaited(controls.clear());
controls.dispose();
}
unawaited(_positionController.close());
unawaited(_errorsController.close());
super.dispose();
}
}