updates and bug fixes
This commit is contained in:
parent
8aacce5aa8
commit
351d47b3ff
9 changed files with 419 additions and 64 deletions
|
|
@ -2,12 +2,15 @@
|
|||
// possible for the private callback fields below.
|
||||
// ignore_for_file: prefer_initializing_formals
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
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';
|
||||
|
||||
|
|
@ -80,10 +83,12 @@ 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)) {
|
||||
|
|
@ -91,15 +96,26 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
_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);
|
||||
|
||||
|
|
@ -318,8 +334,133 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
|||
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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue