This commit is contained in:
Forrest 2026-07-29 14:14:18 -04:00
commit d205277cdd
182 changed files with 22978 additions and 0 deletions

View file

@ -0,0 +1,326 @@
// 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:io' show 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 '../subsonic/models.dart';
/// Immutable snapshot of the player, mirroring Ratune's `QueueState` +
/// player-event stream (`ratune-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 void Function(Uri artUri) onArt,
required void Function(Song song) onPlay,
}) : _streamUriFor = streamUriFor,
_coverArtUriFor = coverArtUriFor,
_onArt = onArt,
_onPlay = onPlay,
super(PlaybackState(supported: _audioSupported)) {
if (_audioSupported) {
_player = AudioPlayer();
_wireStreams();
}
}
final Uri? Function(Song) _streamUriFor;
final Uri? Function(Song) _coverArtUriFor;
final void Function(Uri artUri) _onArt;
final void Function(Song song) _onPlay;
AudioPlayer? _player;
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 (Ratune'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 (Ratune'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);
}
@override
void dispose() {
_player?.dispose();
super.dispose();
}
}