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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user