b6b73fdd0c
Why the seek bar appeared frozen: it was reading PlaybackState.updatePosition, which audio_service only updates on event transitions (play/pause/buffer/seek). Between events it sits unchanged, so the bar only jumped at intervals. Expose just_audio's positionStream (~200ms cadence) from the audio handler, wrap as positionProvider, and read that in both the mini bar and the full player. Now the bar advances continuously while playing. While we're here: spread the full player's vertical layout per operator — gap to seek 20→32, seek-to-primary 8→24, primary-to- secondary 16→24, plus 24 below to match. The full player had visible slack above the controls; this redistributes it.
277 lines
10 KiB
Dart
277 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../cache/audio_cache_manager.dart';
|
|
import '../models/track.dart';
|
|
import 'album_cover_cache.dart';
|
|
|
|
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
|
MinstrelAudioHandler() {
|
|
_player.playbackEventStream.listen(_broadcastState);
|
|
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
|
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
|
|
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
|
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
|
_player.loopModeStream.listen((_) => _broadcastState(null));
|
|
}
|
|
|
|
final AudioPlayer _player = AudioPlayer();
|
|
String _baseUrl = '';
|
|
String? _token;
|
|
AlbumCoverCache? _coverCache;
|
|
AudioCacheManager? _audioCacheManager;
|
|
|
|
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
|
/// volume directly; set via setVolume(double).
|
|
Stream<double> get volumeStream => _player.volumeStream;
|
|
double get volume => _player.volume;
|
|
|
|
/// Position stream for UI subscribers. just_audio emits roughly every
|
|
/// 200ms while playing, which is what makes the seek bar advance
|
|
/// smoothly. PlaybackState.updatePosition only changes on event
|
|
/// transitions (play/pause/buffer), so it's too coarse for live
|
|
/// scrubbing UI.
|
|
Stream<Duration> get positionStream => _player.positionStream;
|
|
Duration get position => _player.position;
|
|
|
|
void configure({
|
|
required String baseUrl,
|
|
required String? token,
|
|
AlbumCoverCache? coverCache,
|
|
AudioCacheManager? audioCacheManager,
|
|
}) {
|
|
_baseUrl = baseUrl;
|
|
_token = token;
|
|
if (coverCache != null) _coverCache = coverCache;
|
|
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
|
debugPrint('audio_handler.configure: baseUrl="$baseUrl" '
|
|
'tokenPresent=${token != null && token.isNotEmpty} '
|
|
'coverCachePresent=${_coverCache != null} '
|
|
'audioCachePresent=${_audioCacheManager != null}');
|
|
}
|
|
|
|
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
|
final items = tracks.map(_toMediaItem).toList();
|
|
queue.add(items);
|
|
if (items.isNotEmpty) {
|
|
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
|
}
|
|
|
|
debugPrint('audio_handler.setQueueFromTracks: '
|
|
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
|
final sources = await Future.wait(tracks.map(_buildAudioSource));
|
|
|
|
await _player.setAudioSources(
|
|
sources,
|
|
initialIndex: initialIndex,
|
|
);
|
|
|
|
// Kick the cover fetch for the initial item — async, doesn't block
|
|
// playback. Subsequent track changes are handled by the
|
|
// currentIndexStream listener.
|
|
unawaited(_loadArtForCurrentItem());
|
|
}
|
|
|
|
String _resolveStreamUrl(TrackRef t) {
|
|
if (t.streamUrl.isEmpty) {
|
|
return '$_baseUrl/api/tracks/${t.id}/stream';
|
|
}
|
|
final parsed = Uri.tryParse(t.streamUrl);
|
|
if (parsed != null && parsed.hasScheme) {
|
|
return t.streamUrl;
|
|
}
|
|
return '$_baseUrl${t.streamUrl}';
|
|
}
|
|
|
|
/// Builds an AudioSource for a track. Cache-aware:
|
|
/// 1. If the track is fully cached on disk, returns a file:// source.
|
|
/// 2. Else returns a LockCachingAudioSource that streams + caches as
|
|
/// it plays (subsequent plays will hit the cache).
|
|
///
|
|
/// Without an audio cache manager configured (e.g. in older code paths
|
|
/// that pre-date #357), falls back to a plain network AudioSource.uri.
|
|
Future<AudioSource> _buildAudioSource(TrackRef t) async {
|
|
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
|
final mgr = _audioCacheManager;
|
|
|
|
// 1. Cache hit: play from disk, no headers needed.
|
|
if (mgr != null) {
|
|
final path = await mgr.pathFor(t.id);
|
|
if (path != null) {
|
|
debugPrint('audio_handler: cache hit for track.id=${t.id} → file://$path');
|
|
return AudioSource.uri(Uri.file(path));
|
|
}
|
|
}
|
|
|
|
final url = _resolveStreamUrl(t);
|
|
final parsed = Uri.parse(url);
|
|
if (!parsed.hasScheme || parsed.host.isEmpty) {
|
|
throw StateError(
|
|
'audio_handler: refused to play scheme-less URL "$url" '
|
|
'(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", '
|
|
'track.id="${t.id}"). configure() must be called with a '
|
|
'non-empty baseUrl before setQueueFromTracks().',
|
|
);
|
|
}
|
|
|
|
// 2. Cache miss WITH manager: stream + cache-as-you-play. Future
|
|
// plays of this track will hit the cache. If the cache write
|
|
// completes, we don't currently register an index row — that would
|
|
// require a download-complete hook from just_audio that's not
|
|
// exposed cleanly. Acceptable for v1: prefetcher / explicit
|
|
// pin / Download buttons cover the index path; LockCaching handles
|
|
// the network optimization.
|
|
if (mgr != null) {
|
|
final cacheDir = await getApplicationCacheDirectory();
|
|
final cacheFile = File('${cacheDir.path}/audio_cache/${t.id}.mp3');
|
|
debugPrint('audio_handler: cache miss for track.id=${t.id}, '
|
|
'using LockCachingAudioSource → ${cacheFile.path}');
|
|
// ignore: experimental_member_use
|
|
return LockCachingAudioSource(parsed,
|
|
headers: headers, cacheFile: cacheFile);
|
|
}
|
|
|
|
// 3. No manager configured: plain network source (legacy path).
|
|
debugPrint('audio_handler: no cache manager; track.id=${t.id} → "$url"');
|
|
return AudioSource.uri(parsed, headers: headers);
|
|
}
|
|
|
|
/// Inserts [track] right after the currently-playing item so it plays
|
|
/// next. If nothing is playing, appends to the end.
|
|
Future<void> playNext(TrackRef track) async {
|
|
final source = await _buildAudioSource(track);
|
|
final item = _toMediaItem(track);
|
|
final currentIdx = _player.currentIndex;
|
|
final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1;
|
|
await _player.insertAudioSource(insertAt, source);
|
|
final current = queue.value;
|
|
queue.add([
|
|
...current.sublist(0, insertAt),
|
|
item,
|
|
...current.sublist(insertAt),
|
|
]);
|
|
}
|
|
|
|
/// Appends [track] to the end of the queue.
|
|
Future<void> enqueue(TrackRef track) async {
|
|
final source = await _buildAudioSource(track);
|
|
final item = _toMediaItem(track);
|
|
await _player.addAudioSource(source);
|
|
queue.add([...queue.value, item]);
|
|
}
|
|
|
|
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.artistName,
|
|
album: t.albumTitle,
|
|
duration: Duration(seconds: t.durationSec),
|
|
// Stash album_id in extras so _loadArtForCurrentItem can pull it
|
|
// back without re-walking the track list.
|
|
extras: t.albumId.isEmpty ? null : {'album_id': t.albumId},
|
|
);
|
|
|
|
void _onCurrentIndexChanged(int? idx) {
|
|
if (idx == null) return;
|
|
unawaited(_loadArtForCurrentItem());
|
|
}
|
|
|
|
/// Async-fetches the cover for whichever item is currently active and
|
|
/// pushes a MediaItem update with artUri set. No-op if no cache is
|
|
/// configured, no current item, the item has no album_id in extras,
|
|
/// or the fetch returns null.
|
|
Future<void> _loadArtForCurrentItem() async {
|
|
final cache = _coverCache;
|
|
if (cache == null) return;
|
|
final current = mediaItem.value;
|
|
if (current == null) return;
|
|
final albumId = current.extras?['album_id'] as String?;
|
|
if (albumId == null || albumId.isEmpty) return;
|
|
if (current.artUri != null) return; // already set
|
|
final path = await cache.getOrFetch(albumId);
|
|
if (path == null) return;
|
|
// Discard if the user advanced to another track while we waited.
|
|
if (mediaItem.value?.id != current.id) return;
|
|
mediaItem.add(current.copyWith(artUri: Uri.file(path)));
|
|
}
|
|
|
|
@override
|
|
Future<void> play() => _player.play();
|
|
|
|
@override
|
|
Future<void> pause() => _player.pause();
|
|
|
|
@override
|
|
Future<void> seek(Duration position) => _player.seek(position);
|
|
|
|
@override
|
|
Future<void> skipToNext() => _player.seekToNext();
|
|
|
|
@override
|
|
Future<void> skipToPrevious() => _player.seekToPrevious();
|
|
|
|
@override
|
|
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
|
await _player
|
|
.setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
|
|
// _broadcastState picks up the change via shuffleModeEnabledStream.
|
|
}
|
|
|
|
@override
|
|
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
|
|
final loop = switch (repeatMode) {
|
|
AudioServiceRepeatMode.none => LoopMode.off,
|
|
AudioServiceRepeatMode.one => LoopMode.one,
|
|
AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
|
|
};
|
|
await _player.setLoopMode(loop);
|
|
}
|
|
|
|
/// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie
|
|
/// page audio to system volume — this is the in-app slider for parity
|
|
/// with desktop/web; on mobile the system volume is the real control.
|
|
Future<void> setVolume(double v) async {
|
|
await _player.setVolume(v.clamp(0.0, 1.0));
|
|
}
|
|
|
|
// _broadcastState accepts a nullable event because the shuffle/repeat
|
|
// listeners don't have one — we just want to re-emit PlaybackState
|
|
// with up-to-date shuffleMode/repeatMode fields.
|
|
void _broadcastState(PlaybackEvent? event) {
|
|
final playing = _player.playing;
|
|
playbackState.add(PlaybackState(
|
|
controls: [
|
|
MediaControl.skipToPrevious,
|
|
if (playing) MediaControl.pause else MediaControl.play,
|
|
MediaControl.skipToNext,
|
|
],
|
|
systemActions: const {MediaAction.seek},
|
|
processingState: switch (_player.processingState) {
|
|
ProcessingState.idle => AudioProcessingState.idle,
|
|
ProcessingState.loading => AudioProcessingState.loading,
|
|
ProcessingState.buffering => AudioProcessingState.buffering,
|
|
ProcessingState.ready => AudioProcessingState.ready,
|
|
ProcessingState.completed => AudioProcessingState.completed,
|
|
},
|
|
playing: playing,
|
|
updatePosition: _player.position,
|
|
bufferedPosition: _player.bufferedPosition,
|
|
speed: _player.speed,
|
|
queueIndex: event?.currentIndex ?? _player.currentIndex,
|
|
shuffleMode: _player.shuffleModeEnabled
|
|
? AudioServiceShuffleMode.all
|
|
: AudioServiceShuffleMode.none,
|
|
repeatMode: switch (_player.loopMode) {
|
|
LoopMode.off => AudioServiceRepeatMode.none,
|
|
LoopMode.one => AudioServiceRepeatMode.one,
|
|
LoopMode.all => AudioServiceRepeatMode.all,
|
|
},
|
|
));
|
|
}
|
|
}
|