Files
plezy/lib/services/music/music_playback_service_impl.dart
T
edde746 41ffaa7f2b fix(automotive): stop playback while a vehicle restricts the app
Plezy declares appCategory="video", so on Android Automotive OS it is a
parked app bound by car app quality DD-2/DD-3: audio must stop when the
vehicle starts driving and must not be resumable while driving. Two paths
kept audio alive. Music playback ran under a mediaPlayback foreground
service whose lifecycle observer was registered for Apple TV only, so it
never paused when Android backgrounded the app. Video pausing hung off
AppLifecycleState.hidden, which Flutter only synthesizes once Android
delivers onStop; a car without the Automotive compatibility mode delivers
onPause alone, which maps to AppLifecycleState.inactive and the player
ignored.

Gate every path that can start audio on a new lifecycle predicate,
automotivePlaybackAllowed, which permits playback on a car only while the
app is resumed and fails closed on an unknown lifecycle state. That covers
explicit play, gapless arming and track transitions, live retry and
channel switch, frame-rate-match resume, VOD/live startup, and the queue
navigation commands of the OS media session, plus a last-resort pause for
when the platform player resumes itself on native audio-focus regain.
Playback authority on the media-session router is deliberately left alone:
the router consumes a denied event, so gating it would swallow PauseEvent
and leave the OS unable to stop audio. Reacting to lifecycle callbacks is
the mechanism the platform documents as sufficient, so no android.car
dependency is added.

The music queue no longer requests POST_NOTIFICATIONS on a car, where the
foreground service and its notification never start: there is nothing to
authorize, and the prompt would take focus and make the gate discard the
first play intent.

Detect the form factor too: FEATURE_AUTOMOTIVE now vetoes the Android TV
verdict, so a rotary-only head unit no longer inherits the leanback
experience. Picture-in-picture is gated on FEATURE_PICTURE_IN_PICTURE,
which cars lack, so the app's UI cannot stay on screen while driving, and
nothing forces a preferred orientation on a fixed-orientation display.
2026-07-28 23:28:25 +02:00

1361 lines
48 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable, visibleForTesting;
import 'package:flutter/widgets.dart';
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 '../../utils/notification_permission.dart';
import '../../utils/platform_detector.dart';
import '../driver_distraction.dart';
import '../media_control_router.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 '../settings_service.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 with WidgetsBindingObserver {
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,
@visibleForTesting Future<void> Function(double)? volumePersistenceWriter,
}) : 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,
_volumePersistenceWriter = volumePersistenceWriter ?? _writePersistedVolume {
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim);
// tvOS has no background-audio session in v1, so it pauses on
// backgrounding. AAOS must stop audio while driving per DD-2. Other
// platforms keep playing under their OS media session.
if (PlatformDetector.isAppleTV() || PlatformDetector.isAutomotive()) {
_observesLifecycle = true;
WidgetsBinding.instance.addObserver(this);
}
}
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 Future<void> Function(double) _volumePersistenceWriter;
final MusicQueueController _queue = MusicQueueController();
/// Persisted music volume (0100), applied to every audio player instance
/// (the core is recreated after video claims playback). Falls back to full
/// volume when settings aren't bootstrapped (tests).
double _volume = SettingsService.instanceOrNull?.read(SettingsService.musicVolume) ?? 100.0;
late final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(_volume);
// One settings write stays in flight while rapid updates replace the single
// pending slot. The drain intentionally survives service disposal.
double? _pendingVolumeWrite;
Future<void>? _volumeWriteDrain;
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;
/// The arm most recently cleared for a re-arm (generation-gated): a queue
/// edit can un-arm an entry in the same instant mpv rolls into it, so the
/// resulting transition must stay adoptable — dropping it leaves the
/// service tracking the finished track for the entire next file.
_ArmedTrack? _staleArm;
int _staleArmGeneration = -1;
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;
/// Queue construction can involve a server round-trip before playback is
/// replaced. Only the latest explicit play intent may commit its result.
int _playIntentGeneration = 0;
/// Identifies the queue session that asynchronous enqueue work belongs to.
int _queueSessionRevision = 0;
/// Gapless arm work is serialized into one latest-request slot. The
/// generation stales continuations while the pending flag distinguishes
/// an explicit recomputation request from cancellation-only invalidation.
int _armRequestGeneration = 0;
bool _armRequestPending = false;
Future<void>? _armDrain;
/// The generation whose replacement [Player.open] has not committed yet.
/// Requests remain pending while an open owns the native playlist.
int? _openingGeneration;
int _consecutiveFailures = 0;
bool _resumeAfterInterruption = false;
bool _disposed = false;
bool _observesLifecycle = false;
Timer? _sleepTimer;
DateTime? _sleepTimerEndsAt;
Duration? _sleepTimerDuration;
bool _sleepTimerEndOfTrack = false;
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast();
final StreamController<Object> _errorsController = StreamController<Object>.broadcast();
// ---------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------
@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
Duration? get sleepTimerDuration => _sleepTimerDuration;
@override
bool get sleepTimerEndOfTrack => _sleepTimerEndOfTrack;
@override
int beginPlayIntent() => ++_playIntentGeneration;
@override
bool isPlayIntentCurrent(int intent) => !_disposed && intent == _playIntentGeneration;
@override
int get queueSessionRevision => _queueSessionRevision;
// ---------------------------------------------------------------------
// Session start
// ---------------------------------------------------------------------
@override
Future<void> playFromList({
required List<MediaItem> tracks,
MediaItem? startTrack,
required MusicPlayContext playContext,
bool shuffle = false,
}) {
beginPlayIntent();
return _startQueue(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle);
}
@override
Future<void> playInstantMix(MediaItem seed) async {
final intent = beginPlayIntent();
final client = _clientFor(seed);
if (client == null) {
if (isPlayIntentCurrent(intent)) {
_errorsController.add(StateError('No server available for instant mix'));
}
return;
}
List<MediaItem> tracks;
try {
tracks = await client.fetchInstantMix(seed.id);
} catch (e, st) {
if (!isPlayIntentCurrent(intent)) return;
appLogger.w('Instant mix fetch failed for ${seed.id}', error: e, stackTrace: st);
_errorsController.add(e);
return;
}
if (!isPlayIntentCurrent(intent) || 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;
beginPlayIntent();
_queueSessionRevision++;
// Android 13+: the background playback notification needs
// POST_NOTIFICATIONS. Fire-and-forget — playback and the foreground
// service run regardless; a denial only hides the notification.
//
// Skipped on a car, where `setBackgroundMode(false)` means the foreground
// service and its notification never start, so there is nothing to
// authorize. The prompt would also take focus, leaving the app briefly not
// resumed, and the automotive gate would then open the track paused and
// silently drop the user's play intent.
if (!PlatformDetector.isAutomotive()) {
unawaited(NotificationPermission.ensure());
}
final generation = ++_generation;
_invalidateArmRequests();
_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;
_openingGeneration = generation;
Player? committedPlayer;
try {
_currentTrack = track;
_currentSource = null;
_armed = null;
_staleArm = null;
_setStatus(MusicPlaybackStatus.loading, forceNotify: true);
await _coordinator.claimMusic();
if (generation != _generation) return;
final player = _ensurePlayer();
_ensureMediaControls();
// Re-asserted per open (cheap, idempotent): the native side drops the
// background-mode opt-in when the user swipes the task away, so a
// session that survives task removal heals itself here.
// Passing false on AAOS also heals any stale opt-in from an earlier session.
unawaited(_mediaControls?.setBackgroundMode(!PlatformDetector.isAutomotive()));
// 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;
final shouldPlay = play && automotivePlaybackAllowedNow();
try {
await player.open(Media(source.url, headers: source.headers), play: shouldPlay);
} 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;
final playbackStarted = shouldPlay && automotivePlaybackAllowedNow();
if (shouldPlay && !playbackStarted) {
await player.pause();
if (generation != _generation || _player != player) return;
}
committedPlayer = player;
_setStatus(playbackStarted ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
_bindTrackServices(track, source);
} finally {
// A stale open must never release a newer open's ownership. Only a
// committed current open schedules its successor; an unsuccessful
// current open cancels requests collected while it was unresolved.
if (_openingGeneration == generation) {
_openingGeneration = null;
if (committedPlayer != null && !_disposed && generation == _generation && _player == committedPlayer) {
_requestArmNext();
} else if (generation == _generation) {
_invalidateArmRequests();
} else {
_ensureArmDrain();
}
}
}
}
/// 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;
_invalidateArmRequests();
_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> _applyArmNext(int generation, int armRequest) async {
final player = _player;
if (player == null || !_isCurrentArmRequest(player, generation, armRequest)) 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)');
_rememberStaleArm();
await _trySetNext(player, null);
return;
}
if (_armed?.track.globalKey == target.globalKey) return;
_rememberStaleArm();
await _trySetNext(player, null);
if (!_isCurrentArmRequest(player, generation, armRequest)) 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 (!_isCurrentArmRequest(player, generation, armRequest)) return;
final armed = _ArmedTrack(track: target, source: source);
_armed = armed;
appLogger.d('Music: arming cursor $targetCursor "${target.title}"');
final ok = await _trySetNext(player, Media(source.url, headers: source.headers));
if (!ok && identical(_armed, armed)) {
// 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;
}
}
bool _isCurrentArmRequest(Player player, int generation, int armRequest) {
return !_disposed &&
_openingGeneration == null &&
generation == _generation &&
armRequest == _armRequestGeneration &&
_player == player;
}
void _requestArmNext() {
if (_disposed || !automotivePlaybackAllowedNow()) return;
_armRequestGeneration++;
_armRequestPending = true;
_ensureArmDrain();
}
void _invalidateArmRequests() {
_armRequestGeneration++;
_armRequestPending = false;
}
void _ensureArmDrain() {
if (_disposed || !_armRequestPending || _openingGeneration != null || _armDrain != null) return;
final drain = _drainArmRequests();
_armDrain = drain;
unawaited(
drain.whenComplete(() {
if (_armDrain != drain) return;
_armDrain = null;
if (!_disposed && _armRequestPending && _openingGeneration == null) {
_ensureArmDrain();
}
}),
);
}
Future<void> _drainArmRequests() async {
while (!_disposed && _armRequestPending && _openingGeneration == null) {
final armRequest = _armRequestGeneration;
final generation = _generation;
_armRequestPending = false;
await _applyArmNext(generation, armRequest);
}
}
/// Un-arm bookkeeping: [_armed] is cleared but remembered so
/// [_onTrackTransition] can adopt a transition that raced the clear.
void _rememberStaleArm() {
if (_armed != null) {
_staleArm = _armed;
_staleArmGeneration = _generation;
}
_armed = null;
}
Future<bool> _trySetNext(Player player, Media? media) async {
if (media != null && !automotivePlaybackAllowedNow()) return false;
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;
_requestArmNext();
}
// ---------------------------------------------------------------------
// Player events
// ---------------------------------------------------------------------
Player _ensurePlayer() {
final existing = _player;
if (existing != null && !existing.disposed) return existing;
final player = _audioPlayerFactory();
_player = player;
_wirePlayerStreams(player);
if (_volume != 100.0) unawaited(player.setVolume(_volume));
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) {
final playbackAllowed = automotivePlaybackAllowedNow();
final shouldBePlaying = isPlaying && playbackAllowed;
if (isPlaying && !playbackAllowed) {
unawaited(_player?.pause());
}
if (_status == MusicPlaybackStatus.playing || _status == MusicPlaybackStatus.paused) {
_setStatus(shouldBePlaying ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
unawaited(_tracker?.sendProgress(shouldBePlaying ? 'playing' : 'paused'));
}
final player = _player;
if (player != null) {
_mediaControls?.updatePlaybackState(
isPlaying: shouldBePlaying && 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) {
var armed = _armed;
if ((armed == null || armed.source.url != uri) &&
_staleArm != null &&
_staleArmGeneration == _generation &&
_staleArm!.source.url == uri) {
// mpv rolled into the entry in the same instant a queue edit un-armed
// it — the transition is still authoritative for what is audibly
// playing.
armed = _staleArm;
}
_staleArm = null;
if (armed == null || armed.source.url != uri) {
appLogger.w('Unexpected track transition to $uri (armed: ${armed?.source.url})');
return;
}
final adopted = armed;
_armed = null;
_generation++;
_invalidateArmRequests();
// 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 == adopted.track.globalKey) {
_queue.jumpTo(expected);
} else {
final index = _queue.queue.indexWhere((t) => t.globalKey == adopted.track.globalKey);
if (index < 0) {
// The track mpv advanced into was removed from the queue at the
// boundary: don't adopt it — play what the queue says comes next,
// or park when nothing does.
appLogger.d('Music: transition into removed track "${adopted.track.title}" — advancing past it');
final nextCursor = _queue.nextIndex();
if (nextCursor != null) {
unawaited(_advanceTo(nextCursor));
} else {
unawaited(_player?.pause());
_parkAtEnd();
}
return;
}
_queue.jumpTo(index);
}
_currentTrack = _queue.current ?? adopted.track;
_currentSource = adopted.source;
_consecutiveFailures = 0;
appLogger.d('Music: transition received "${adopted.track.title}" → cursor ${_queue.cursor}');
if (automotivePlaybackAllowedNow()) {
_setStatus(MusicPlaybackStatus.playing, forceNotify: true);
} else {
unawaited(_player?.pause());
_setStatus(MusicPlaybackStatus.paused, forceNotify: true);
}
_bindTrackServices(_currentTrack!, adopted.source);
_requestArmNext();
}
/// 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++;
_invalidateArmRequests();
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(
canPlayPause: true,
canGoNext: _queue.nextIndex(manual: true) != null,
// Previous always restarts the track even at queue head.
canGoPrevious: true,
canSeek: true,
canStop: true,
// In-track skips: Bluetooth/steering-wheel fast-forward and rewind
// buttons map here on Android. (Never surfaced on iOS/macOS — see
// MediaControlsManager.setControlsEnabled.)
canSkip: true,
// Music always plays at 1.0 — never advertise a speed control.
),
);
}
/// OS transport commands. Music has no authorization gate for playback: the
/// session only exists while a track is loaded, and that is checked in
/// [_onControlEvent]. The automotive gate deliberately does NOT sit on
/// [MediaControlRouter.canControlPlayback] — the router consumes a denied
/// event, so gating it there would swallow `PauseEvent` and leave the OS
/// unable to stop audio. Starting audio is gated inside [play] instead.
late final _mediaControlRouter = MediaControlRouter(
canControlPlayback: () => true,
canNavigateMediaItems: automotivePlaybackAllowedNow,
onPlay: () => unawaited(play()),
onPause: () => unawaited(pause()),
onTogglePlayPause: () => unawaited(togglePlayPause()),
onSeek: (position) => unawaited(seek(position)),
onNext: () => unawaited(next()),
onPrevious: () => unawaited(previous()),
onStop: () => unawaited(stop()),
onSkipForward: (interval) => unawaited(_seekRelative(interval ?? _defaultSkipInterval)),
onSkipBackward: (interval) => unawaited(_seekRelative(-(interval ?? _defaultSkipInterval))),
// Speed is deliberately ignored: music always plays at 1.0 and the control
// is not advertised — but Linux MPRIS exposes an always-writable Rate
// property, so the event can still arrive. The periodic playback-state
// update reasserts speed 1.0.
onSetSpeed: (_) {},
);
void _onControlEvent(MediaControlEvent event) {
if (_disposed || _currentTrack == null) return;
if (_mediaControlRouter.route(event)) return;
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());
}
}
}
static const _defaultSkipInterval = Duration(seconds: 15);
/// In-track relative seek for OS skip commands, clamped to the track.
Future<void> _seekRelative(Duration delta) async {
final player = _player;
if (player == null) return;
var target = player.currentPosition + delta;
if (target < Duration.zero) target = Duration.zero;
final max = duration;
if (max != null && target > max) target = max;
await player.seek(target);
}
// ---------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------
@override
Future<void> play() async {
if (!automotivePlaybackAllowedNow()) {
appLogger.d('Music play denied while automotive playback is restricted');
return;
}
final player = _player;
if (player == null || _currentTrack == null) return;
final generation = _generation;
if (player.state.completed) {
// Parked at queue end: restart the current track.
await player.seek(Duration.zero);
if (!_isCurrentTransport(player, generation)) return;
final currentTrack = _currentTrack;
final currentSource = _currentSource;
if (currentTrack != null && currentSource != null) {
_bindTrackServices(currentTrack, currentSource);
}
_requestArmNext();
}
if (!automotivePlaybackAllowedNow()) {
appLogger.d('Music play denied while automotive playback is restricted');
return;
}
await player.play();
if (!_isCurrentTransport(player, generation)) return;
if (!automotivePlaybackAllowedNow()) {
await player.pause();
if (_isCurrentTransport(player, generation)) {
_setStatus(MusicPlaybackStatus.paused);
}
return;
}
_setStatus(MusicPlaybackStatus.playing);
// A restriction cleared the native arm on the way in; restore it so gapless
// playback survives a park-and-resume cycle.
if (PlatformDetector.isAutomotive()) _requestArmNext();
}
@override
Future<void> pause() async {
final player = _player;
if (player == null || _currentTrack == null) return;
final generation = _generation;
await player.pause();
if (!_isCurrentTransport(player, generation)) return;
_setStatus(MusicPlaybackStatus.paused);
}
bool _isCurrentTransport(Player player, int generation) {
return !_disposed && _currentTrack != null && generation == _generation && _player == player;
}
@override
Future<void> togglePlayPause() {
final player = _player;
if (player == null) return Future.value();
return player.state.isActive ? pause() : play();
}
/// On Apple TV, pause when the app leaves the foreground because tvOS
/// background audio is not attempted in v1. On AAOS, stop audio whenever
/// the app is not resumed to comply with driver-distraction rule DD-2.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (_disposed) return;
if (PlatformDetector.isAutomotive()) {
if (!automotivePlaybackAllowedNow()) {
_invalidateArmRequests();
_rememberStaleArm();
final player = _player;
if (player != null) {
unawaited(_trySetNext(player, null));
}
if (isPlaying) {
appLogger.d('App restricted on Android Automotive — pausing music playback');
unawaited(pause());
}
return;
}
// Restrictions lifted: re-arm the next track that was cleared on entry.
// Playback itself stays paused until the user asks for it.
if (_currentTrack != null) _requestArmNext();
return;
}
if (PlatformDetector.isAppleTV() &&
(state == AppLifecycleState.paused || state == AppLifecycleState.hidden) &&
isPlaying) {
appLogger.d('App backgrounded on Apple TV — pausing music playback');
unawaited(pause());
}
}
@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
double get volume => _volume;
@override
ValueListenable<double> get volumeListenable => _volumeNotifier;
@override
Future<void> setVolume(double volume, {bool persist = true}) async {
final clamped = volume.clamp(0.0, 100.0);
Future<void>? playerUpdate;
if (clamped != _volume) {
_volume = clamped;
_volumeNotifier.value = clamped;
playerUpdate = _player?.setVolume(clamped);
}
final persistence = persist ? _persistVolume(clamped) : null;
if (playerUpdate != null && persistence != null) {
await Future.wait([playerUpdate, persistence]);
} else {
await playerUpdate;
await persistence;
}
}
static Future<void> _writePersistedVolume(double volume) async {
final settings = SettingsService.instanceOrNull;
if (settings != null) await settings.write(SettingsService.musicVolume, volume);
}
Future<void> _persistVolume(double volume) {
_pendingVolumeWrite = volume;
final activeDrain = _volumeWriteDrain;
if (activeDrain != null) return activeDrain;
final completer = Completer<void>();
_volumeWriteDrain = completer.future;
unawaited(_drainVolumeWrites(completer));
return completer.future;
}
Future<void> _drainVolumeWrites(Completer<void> completer) async {
Object? firstError;
StackTrace? firstStackTrace;
while (_pendingVolumeWrite != null) {
final volume = _pendingVolumeWrite!;
_pendingVolumeWrite = null;
try {
await _volumePersistenceWriter(volume);
} catch (error, stackTrace) {
firstError ??= error;
firstStackTrace ??= stackTrace;
}
}
_volumeWriteDrain = null;
if (firstError != null) {
completer.completeError(firstError, firstStackTrace);
} else {
completer.complete();
}
}
@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;
_sleepTimerDuration = null;
final hadEndOfTrack = _sleepTimerEndOfTrack;
_sleepTimerEndOfTrack = endOfTrack;
if (!endOfTrack && duration != null) {
_sleepTimerEndsAt = DateTime.now().add(duration);
_sleepTimerDuration = 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) {
_requestArmNext();
}
notifyListeners();
}
void _onSleepTimerFired() {
_sleepTimer = null;
_sleepTimerEndsAt = null;
_sleepTimerDuration = null;
unawaited(pause());
notifyListeners();
}
void _cancelSleepTimer() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerEndsAt = null;
_sleepTimerDuration = 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 {
beginPlayIntent();
_queueSessionRevision++;
_generation++;
_invalidateArmRequests();
_cancelTimersAndFinalizeTrack();
_queue.clear();
_currentTrack = null;
_currentSource = null;
_armed = null;
_staleArm = null;
_playContext = null;
_resumeAfterInterruption = false;
_setStatus(endStatus, forceNotify: true);
await _teardownPlayerAndControls(awaitStop: true);
}
/// Kills the completion/sleep timers and flushes the track's final progress
/// report — done before [_setStatus] so listeners never see a live timer.
void _cancelTimersAndFinalizeTrack() {
_completedConfirmTimer?.cancel();
_completedConfirmTimer = null;
_cancelSleepTimer();
_finalizeCurrentTrack();
}
/// Detaches the player streams, shuts the player down and drops the OS media
/// session — the teardown shared by [_stopSession] and [dispose].
///
/// [awaitStop] stops the player and awaits every step, so callers know the
/// audio core is gone once the future resolves. The `false` path must never
/// suspend: [dispose] is a synchronous override and needs the whole teardown
/// to run in the caller's turn, before `super.dispose()`.
Future<void> _teardownPlayerAndControls({required bool awaitStop}) async {
for (final sub in _playerSubs) {
unawaited(sub.cancel());
}
_playerSubs.clear();
final player = _player;
_player = null;
if (player != null && !player.disposed) {
if (awaitStop) {
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);
}
} else {
unawaited(
player.abandonAudioFocus().catchError((Object e) {
appLogger.d('Audio focus abandon failed during dispose', error: e);
}),
);
unawaited(player.dispose());
}
}
unawaited(_controlEventsSub?.cancel());
_controlEventsSub = null;
final controls = _mediaControls;
_mediaControls = null;
if (controls != null) {
unawaited(controls.setBackgroundMode(false));
unawaited(controls.clear());
controls.dispose();
}
}
@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;
_playIntentGeneration++;
_queueSessionRevision++;
_generation++;
_invalidateArmRequests();
_disposed = true;
if (_observesLifecycle) {
WidgetsBinding.instance.removeObserver(this);
_observesLifecycle = false;
}
_coordinator.unregisterMusicSession(_stopForVideoClaim);
_cancelTimersAndFinalizeTrack();
// Runs to completion synchronously — see the awaitStop: false contract.
unawaited(_teardownPlayerAndControls(awaitStop: false));
unawaited(_positionController.close());
unawaited(_errorsController.close());
_volumeNotifier.dispose();
super.dispose();
}
}