feat(flutter/player): audio_handler reads from cache first; LockCaching fallback

_buildAudioSource is now async and cache-aware:
1. Cache hit  → AudioSource.uri(file://path)
2. Cache miss with manager → LockCachingAudioSource (cache-as-you-play)
3. No manager configured → plain AudioSource.uri (legacy fallback)

playerActionsProvider.playTracks now passes audioCacheManager into
configure() alongside coverCache. setQueueFromTracks awaits the source
build (Future.wait over the track list).

Out of scope: registering an index row when LockCachingAudioSource
finishes downloading (no clean hook from just_audio). Prefetcher /
Download buttons cover the index path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 23:20:24 -04:00
parent 536350380f
commit 153e931490
2 changed files with 57 additions and 12 deletions
+49 -11
View File
@@ -1,9 +1,12 @@
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';
@@ -17,18 +20,22 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
String _baseUrl = '';
String? _token;
AlbumCoverCache? _coverCache;
AudioCacheManager? _audioCacheManager;
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} '
'cachePresent=${_coverCache != null}');
'coverCachePresent=${_coverCache != null} '
'audioCachePresent=${_audioCacheManager != null}');
}
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
@@ -40,7 +47,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
debugPrint('audio_handler.setQueueFromTracks: '
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
final sources = tracks.map(_buildAudioSource).toList();
final sources = await Future.wait(tracks.map(_buildAudioSource));
await _player.setAudioSources(
sources,
@@ -64,15 +71,27 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
return '$_baseUrl${t.streamUrl}';
}
/// Builds a single AudioSource for a track, applying URL resolution
/// against _baseUrl and attaching the Bearer header. Throws StateError
/// if the resolved URL is scheme-less (which would otherwise crash
/// ExoPlayer with a confusing localhost cleartext error).
AudioSource _buildAudioSource(TrackRef t) {
/// 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);
debugPrint('audio_handler: track.id=${t.id} '
'track.streamUrl="${t.streamUrl}" → resolved="$url"');
final parsed = Uri.parse(url);
if (!parsed.hasScheme || parsed.host.isEmpty) {
throw StateError(
@@ -82,13 +101,32 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
'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}');
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 = _buildAudioSource(track);
final source = await _buildAudioSource(track);
final item = _toMediaItem(track);
final currentIdx = _player.currentIndex;
final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1;
@@ -103,7 +141,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
/// Appends [track] to the end of the queue.
Future<void> enqueue(TrackRef track) async {
final source = _buildAudioSource(track);
final source = await _buildAudioSource(track);
final item = _toMediaItem(track);
await _player.addAudioSource(source);
queue.add([...queue.value, item]);
@@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import '../cache/audio_cache_manager.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/track.dart';
import 'album_cover_cache.dart';
@@ -37,8 +38,14 @@ class PlayerActions {
final url = await _ref.read(serverUrlProvider.future);
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
final cache = _ref.read(albumCoverCacheProvider);
final audioCache = _ref.read(audioCacheManagerProvider);
final h = _ref.read(audioHandlerProvider)
..configure(baseUrl: url ?? '', token: token, coverCache: cache);
..configure(
baseUrl: url ?? '',
token: token,
coverCache: cache,
audioCacheManager: audioCache,
);
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
await h.play();
}