// Named constructor params can't be private, so initializing formals aren't // possible for the private callback fields below. // ignore_for_file: prefer_initializing_formals import 'dart:async'; import 'dart:convert'; import 'dart:io' show File, Platform; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; import 'package:just_audio_background/just_audio_background.dart'; import 'package:path_provider/path_provider.dart'; import '../subsonic/models.dart'; /// Immutable snapshot of the player, mirroring Timbre's `QueueState` + /// player-event stream (`timbre-player/src/engine.rs`). class PlaybackState { const PlaybackState({ this.queue = const [], this.currentIndex, this.playing = false, this.position = Duration.zero, this.duration = Duration.zero, this.shuffle = false, this.loop = LoopMode.off, this.supported = true, }); final List queue; final int? currentIndex; final bool playing; final Duration position; final Duration duration; final bool shuffle; final LoopMode loop; /// False on platforms without a just_audio backend (e.g. Linux desktop /// without media_kit). The UI still reflects the selected track; only audio /// output is unavailable. final bool supported; Song? get current => (currentIndex != null && currentIndex! >= 0 && currentIndex! < queue.length) ? queue[currentIndex!] : null; double get progress { final total = duration.inMilliseconds; if (total <= 0) return 0; return (position.inMilliseconds / total).clamp(0.0, 1.0); } PlaybackState copyWith({ List? queue, int? currentIndex, bool? playing, Duration? position, Duration? duration, bool? shuffle, LoopMode? loop, bool? supported, }) { return PlaybackState( queue: queue ?? this.queue, currentIndex: currentIndex ?? this.currentIndex, playing: playing ?? this.playing, position: position ?? this.position, duration: duration ?? this.duration, shuffle: shuffle ?? this.shuffle, loop: loop ?? this.loop, supported: supported ?? this.supported, ); } } /// Owns the just_audio player and translates Subsonic songs into a gapless /// queue. Playback methods no-op where audio is unsupported, but queue/current /// state and album-art accent extraction still run so the UI is fully alive on /// the Linux dev target. class PlaybackController extends StateNotifier { PlaybackController({ required Uri? Function(Song) streamUriFor, required Uri? Function(Song) coverArtUriFor, required String? Function() serverKeyGetter, required void Function(Uri artUri) onArt, required void Function(Song song) onPlay, }) : _streamUriFor = streamUriFor, _coverArtUriFor = coverArtUriFor, _serverKeyGetter = serverKeyGetter, _onArt = onArt, _onPlay = onPlay, super(PlaybackState(supported: _audioSupported)) { if (_audioSupported) { _player = AudioPlayer(); _wireStreams(); } // Persist the queue on any change (throttled) so it survives an app kill. addListener((_) => _scheduleSave(), fireImmediately: false); } final Uri? Function(Song) _streamUriFor; final Uri? Function(Song) _coverArtUriFor; final String? Function() _serverKeyGetter; final void Function(Uri artUri) _onArt; final void Function(Song song) _onPlay; AudioPlayer? _player; /// Coalesces rapid state changes (a position tick every second) into at most /// one disk write per window. Timer? _saveTimer; /// Server key whose queue we've already restored (or adopted). Gates saves so /// the empty launch state can't clobber a snapshot before restore runs. String? _restoredKey; static bool get _audioSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); void _wireStreams() { final player = _player!; player.currentIndexStream.listen((i) { if (i == null) return; state = state.copyWith(currentIndex: i); _notifyCurrent(); }); player.playerStateStream.listen((s) { state = state.copyWith(playing: s.playing); }); player.positionStream.listen((p) { state = state.copyWith(position: p); }); player.durationStream.listen((d) { if (d != null) state = state.copyWith(duration: d); }); } /// Id of the song we last ran play side effects for. Queue edits shift /// `currentIndex` (and re-emit `currentIndexStream`) without changing the /// playing track, so we dedupe on song id to avoid re-scrobbling / re-running /// accent extraction when the current song hasn't actually changed. String? _lastNotifiedId; /// Monotonic tag counter — every AudioSource gets a globally-unique MediaItem /// id even when the same song appears in the queue twice. just_audio_background /// keys its notification off the tag id, so duplicate ids would confuse it. int _tagSeq = 0; void _notifyCurrent() { final song = state.current; if (song == null) return; if (song.id == _lastNotifiedId) return; _lastNotifiedId = song.id; final art = _coverArtUriFor(song); if (art != null) _onArt(art); _onPlay(song); } AudioSource _sourceFor(Song song) { final uri = _streamUriFor(song)!; final art = _coverArtUriFor(song); return AudioSource.uri( uri, tag: MediaItem( id: '${song.id}#${_tagSeq++}', title: song.title ?? 'Unknown', album: song.album, artist: song.artist, duration: song.duration != null ? Duration(seconds: song.duration!) : null, artUri: art, ), ); } /// Replace the queue with [songs] and start at [startIndex]. /// /// `state.queue` is set to exactly the *streamable* subset that gets loaded /// into the player, so queue index == player source index 1:1. Every later /// queue mutation (`playNext` / `addToQueue` / `removeAt`) relies on that /// invariant to stay aligned. Future playSongs(List songs, {int startIndex = 0}) async { if (songs.isEmpty) return; final streamable = songs.where((s) => _streamUriFor(s) != null).toList(); if (streamable.isEmpty) { // Not connected / nothing playable — reflect the selection for the UI // only; no player sources exist to mutate. state = state.copyWith( queue: songs, currentIndex: startIndex.clamp(0, songs.length - 1), position: Duration.zero, ); _lastNotifiedId = null; _notifyCurrent(); return; } // Remap the requested start into the filtered list (the chosen song may // itself have been unstreamable). final target = songs[startIndex.clamp(0, songs.length - 1)]; final targetIndex = streamable.indexOf(target); final start = targetIndex >= 0 ? targetIndex : startIndex.clamp(0, streamable.length - 1); state = state.copyWith( queue: streamable, currentIndex: start, position: Duration.zero, ); // An explicit play should always (re)scrobble, even if it's the same song. _lastNotifiedId = null; final player = _player; if (player == null) { // Linux/desktop: no streams will fire, so reflect the selection (art + // history) directly. UI-only, no audio output. _notifyCurrent(); return; } await player.setAudioSources( streamable.map(_sourceFor).toList(), initialIndex: start, ); await player.play(); // currentIndexStream fires _notifyCurrent for the started track. } /// Insert [song] right after the current track (Timbre's "play next"). /// Falls back to [playSongs] when nothing is playing. Future playNext(Song song) async { if (_streamUriFor(song) == null) return; final q = state.queue; final current = state.currentIndex; if (q.isEmpty || current == null) { await playSongs([song]); return; } final at = (current + 1).clamp(0, q.length); state = state.copyWith(queue: [...q]..insert(at, song)); await _player?.insertAudioSource(at, _sourceFor(song)); } /// Append [song] to the end of the queue. Future addToQueue(Song song) async { if (_streamUriFor(song) == null) return; final q = state.queue; if (q.isEmpty || state.currentIndex == null) { await playSongs([song]); return; } state = state.copyWith(queue: [...q, song]); await _player?.addAudioSource(_sourceFor(song)); } /// Remove the queue entry at [index], keeping `state.queue` and the player /// playlist aligned. On mobile just_audio adjusts its own current index and /// re-emits `currentIndexStream`; the id dedupe in [_notifyCurrent] avoids a /// spurious re-scrobble when the playing track didn't actually change. Future removeAt(int index) async { final q = state.queue; if (index < 0 || index >= q.length) return; final next = [...q]..removeAt(index); if (next.isEmpty) { await _player?.clearAudioSources(); state = PlaybackState( shuffle: state.shuffle, loop: state.loop, supported: state.supported, ); _lastNotifiedId = null; return; } final player = _player; if (player == null) { // Desktop/no-audio: no stream will correct the index for us. final current = state.currentIndex; var newIndex = current; if (current != null) { if (index < current) { newIndex = current - 1; } else if (index == current) { // The removed slot now holds the following track. newIndex = current.clamp(0, next.length - 1); } } state = state.copyWith(queue: next, currentIndex: newIndex); _notifyCurrent(); return; } state = state.copyWith(queue: next); await player.removeAudioSourceAt(index); } Future togglePlayPause() async { final player = _player; if (player == null) return; if (player.playing) { await player.pause(); } else { await player.play(); } } Future next() async => _player?.seekToNext(); Future previous() async => _player?.seekToPrevious(); Future seek(Duration position) async => _player?.seek(position); Future toggleShuffle() async { final enabled = !state.shuffle; state = state.copyWith(shuffle: enabled); final player = _player; if (player != null) { await player.setShuffleModeEnabled(enabled); } } /// Cycle off → all → one → off (Timbre's queue-loop toggle, extended with /// single-track repeat). Future cycleLoop() async { final nextMode = switch (state.loop) { LoopMode.off => LoopMode.all, LoopMode.all => LoopMode.one, LoopMode.one => LoopMode.off, }; state = state.copyWith(loop: nextMode); await _player?.setLoopMode(nextMode); } // ---- Persistence -------------------------------------------------------- /// Per-server queue snapshot file, alongside the playlists/downloads mirrors. Future _snapshotFile(String key) async { final dir = await getApplicationSupportDirectory(); return File('${dir.path}/queue_$key.json'); } /// Throttle a save: schedule one write per window, capturing the latest state /// when it fires (so continuous position ticks don't hammer the disk). void _scheduleSave() { if (_saveTimer?.isActive ?? false) return; _saveTimer = Timer(const Duration(seconds: 3), _persist); } Future _persist() async { final key = _serverKeyGetter(); // Only persist once the active server's queue has been restored/adopted. if (key == null || key != _restoredKey) return; try { final file = await _snapshotFile(key); final tmp = File('${file.path}.tmp'); await tmp.writeAsString(jsonEncode({ 'queue': state.queue.map((s) => s.toJson()).toList(), 'currentIndex': state.currentIndex, 'positionMs': state.position.inMilliseconds, 'shuffle': state.shuffle, 'loop': state.loop.name, })); await tmp.rename(file.path); // atomic swap } catch (_) { // Best effort — a failed save just means a slightly staler queue. } } /// Restore the saved queue for the active server (on connect / server switch). /// The tracks, shuffle/loop and position are loaded back into the player /// **paused**, so the queue auto-populates without surprising the user with /// sudden playback. Called from the [serverKeyProvider] listener; a no-op once /// the current server has already been restored. Future restoreForServer() async { final key = _serverKeyGetter(); if (key == null || key == _restoredKey) return; Map? snap; try { final file = await _snapshotFile(key); if (await file.exists()) { final raw = jsonDecode(await file.readAsString()); if (raw is Map) snap = raw.cast(); } } catch (_) { // Missing/corrupt snapshot is non-fatal. } // Mark restored *after* reading, so saves for this server are now allowed. _restoredKey = key; // Don't clobber an already-loaded queue (user started playback first). if (state.queue.isNotEmpty || snap == null) return; final songs = (snap['queue'] as List? ?? const []) .whereType() .map((e) => Song.fromJson(e.cast())) .toList(); if (songs.isEmpty) return; final loop = LoopMode.values.firstWhere( (m) => m.name == snap!['loop'], orElse: () => LoopMode.off, ); final shuffle = snap['shuffle'] == true; final savedIndex = (snap['currentIndex'] as int?) ?? 0; final savedPos = Duration(milliseconds: (snap['positionMs'] as int?) ?? 0); final streamable = songs.where((s) => _streamUriFor(s) != null).toList(); if (streamable.isEmpty) { // Offline / nothing streamable: reflect the queue for the UI only. final start = savedIndex.clamp(0, songs.length - 1); _lastNotifiedId = songs[start].id; state = state.copyWith( queue: songs, currentIndex: start, position: savedPos, shuffle: shuffle, loop: loop, ); return; } // Remap the saved index onto the streamable subset (same invariant as // [playSongs]: state.queue == the sources loaded into the player). final target = songs[savedIndex.clamp(0, songs.length - 1)]; final targetIndex = streamable.indexOf(target); final start = targetIndex >= 0 ? targetIndex : savedIndex.clamp(0, streamable.length - 1); // Suppress a spurious scrobble/history entry for the restored (not actually // played) track, but still drive the album-art accent. _lastNotifiedId = streamable[start].id; final art = _coverArtUriFor(streamable[start]); if (art != null) _onArt(art); state = state.copyWith( queue: streamable, currentIndex: start, position: savedPos, shuffle: shuffle, loop: loop, ); final player = _player; if (player == null) return; // desktop: UI-only, already reflected above. await player.setAudioSources( streamable.map(_sourceFor).toList(), initialIndex: start, ); await player.setShuffleModeEnabled(shuffle); await player.setLoopMode(loop); if (savedPos > Duration.zero) await player.seek(savedPos, index: start); // Intentionally no play() — restore leaves the queue paused. } @override void dispose() { _saveTimer?.cancel(); _player?.dispose(); super.dispose(); } }