Files
minstrel/flutter_client/lib/player/audio_handler.dart
T
bvandeusen 0d171490c3 fix(flutter): analyze errors after Plan B push
Cleared 5 errors + 1 warning + 1 info from CI flutter analyze on 5114a81:

- prefetcher.dart: Riverpod listener callbacks used (_, _) for two
  placeholder params — Dart 3 enforces unique names. Changed to (_, __).
- prefetcher.dart: Riverpod 3's AsyncValue exposes `.value` (nullable)
  not `.valueOrNull`. Three call sites updated.
- sync_controller.dart: doc comment had `?since=<cursor>` → analyzer
  warned about unintended HTML. Wrapped in backticks with `{cursor}`.
- sync_controller.dart: delete loop over heterogeneous Table list
  inferred as List<Table>; drift's delete() expects TableInfo. Unrolled
  to explicit per-table deletes.
- audio_cache_manager_test.dart: db.into(...).insertAll([...]) doesn't
  exist on InsertStatement — only insert/insertOnConflictUpdate. Used
  db.batch((b) => b.insertAll(table, [...])) instead, in two test cases.
- audio_handler.dart: LockCachingAudioSource is marked experimental in
  just_audio. Added // ignore: experimental_member_use — operator
  acknowledged the experimental status during brainstorming and we're
  the project; CI's --fatal-infos would otherwise gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:18:38 -04:00

225 lines
8.1 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);
}
final AudioPlayer _player = AudioPlayer();
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} '
'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();
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,
));
}
}