467 lines
15 KiB
Dart
467 lines
15 KiB
Dart
// 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<Song> 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<Song>? 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<PlaybackState> {
|
|
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<void> playSongs(List<Song> 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<void> 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<void> 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<void> 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<void> togglePlayPause() async {
|
|
final player = _player;
|
|
if (player == null) return;
|
|
if (player.playing) {
|
|
await player.pause();
|
|
} else {
|
|
await player.play();
|
|
}
|
|
}
|
|
|
|
Future<void> next() async => _player?.seekToNext();
|
|
|
|
Future<void> previous() async => _player?.seekToPrevious();
|
|
|
|
Future<void> seek(Duration position) async => _player?.seek(position);
|
|
|
|
Future<void> 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<void> 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<File> _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<void> _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<void> restoreForServer() async {
|
|
final key = _serverKeyGetter();
|
|
if (key == null || key == _restoredKey) return;
|
|
|
|
Map<String, dynamic>? snap;
|
|
try {
|
|
final file = await _snapshotFile(key);
|
|
if (await file.exists()) {
|
|
final raw = jsonDecode(await file.readAsString());
|
|
if (raw is Map) snap = raw.cast<String, dynamic>();
|
|
}
|
|
} 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>()
|
|
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
|
|
.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();
|
|
}
|
|
}
|