diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index b59ad8f8..732bfdaa 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -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 setQueueFromTracks(List 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 _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 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 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]); diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart index 6f09fe5e..1c00eac1 100644 --- a/flutter_client/lib/player/player_provider.dart +++ b/flutter_client/lib/player/player_provider.dart @@ -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(); }