Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5ab471ce1 | |||
| 28b0107925 | |||
| bfad4dddb6 | |||
| d1e276204e | |||
| 2df35e6227 |
@@ -27,6 +27,32 @@ class AlbumCoverCache {
|
|||||||
/// same album dedupe to one fetch.
|
/// same album dedupe to one fetch.
|
||||||
final Map<String, Future<String?>> _inflight = {};
|
final Map<String, Future<String?>> _inflight = {};
|
||||||
|
|
||||||
|
/// Cached covers directory path resolved once on the first
|
||||||
|
/// async cacheDir call, then reused for sync existsSync() checks
|
||||||
|
/// in [peekCached]. Without this every "is the cover already on
|
||||||
|
/// disk?" check would have to await path_provider, blocking the
|
||||||
|
/// MediaItem broadcast on every track change.
|
||||||
|
String? _coversDirPath;
|
||||||
|
|
||||||
|
/// Returns the local file path for [albumId]'s cover if it's
|
||||||
|
/// already cached on disk, or null otherwise. Synchronous — uses
|
||||||
|
/// File.existsSync() against a path computed from the directory
|
||||||
|
/// resolved by an earlier async [getOrFetch] call. Returns null
|
||||||
|
/// until at least one getOrFetch has populated _coversDirPath.
|
||||||
|
///
|
||||||
|
/// The audio handler uses this to seed MediaItem.artUri on the
|
||||||
|
/// initial broadcast so external media controllers (Wear, Android
|
||||||
|
/// Auto, Bluetooth) see the cover on the first frame for warm-cache
|
||||||
|
/// tracks. Cold-cache tracks still fall back to the async
|
||||||
|
/// _loadArtForCurrentItem path.
|
||||||
|
String? peekCached(String albumId) {
|
||||||
|
if (albumId.isEmpty) return null;
|
||||||
|
final dir = _coversDirPath;
|
||||||
|
if (dir == null) return null;
|
||||||
|
final path = '$dir/$albumId.jpg';
|
||||||
|
return File(path).existsSync() ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns local file path to the album cover, or null on any
|
/// Returns local file path to the album cover, or null on any
|
||||||
/// failure (network, 4xx/5xx, disk full, empty albumId).
|
/// failure (network, 4xx/5xx, disk full, empty albumId).
|
||||||
Future<String?> getOrFetch(String albumId) {
|
Future<String?> getOrFetch(String albumId) {
|
||||||
@@ -44,6 +70,9 @@ class AlbumCoverCache {
|
|||||||
final dir = await _cacheDirFactory();
|
final dir = await _cacheDirFactory();
|
||||||
final coversDir = Directory('${dir.path}/album_covers');
|
final coversDir = Directory('${dir.path}/album_covers');
|
||||||
await coversDir.create(recursive: true);
|
await coversDir.create(recursive: true);
|
||||||
|
// Cache the resolved directory so [peekCached] can do its
|
||||||
|
// existsSync() check without re-awaiting path_provider.
|
||||||
|
_coversDirPath = coversDir.path;
|
||||||
final filePath = '${coversDir.path}/$albumId.jpg';
|
final filePath = '${coversDir.path}/$albumId.jpg';
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
if (await file.exists()) return filePath;
|
if (await file.exists()) return filePath;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
String? _token;
|
String? _token;
|
||||||
AlbumCoverCache? _coverCache;
|
AlbumCoverCache? _coverCache;
|
||||||
AudioCacheManager? _audioCacheManager;
|
AudioCacheManager? _audioCacheManager;
|
||||||
|
LikeBridge? _likeBridge;
|
||||||
|
|
||||||
/// Trackers to dedupe registration — once we've inserted an index row
|
/// Trackers to dedupe registration — once we've inserted an index row
|
||||||
/// for a trackId, don't repeat the work on every buffered-position
|
/// for a trackId, don't repeat the work on every buffered-position
|
||||||
@@ -94,10 +95,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
required String? token,
|
required String? token,
|
||||||
AlbumCoverCache? coverCache,
|
AlbumCoverCache? coverCache,
|
||||||
AudioCacheManager? audioCacheManager,
|
AudioCacheManager? audioCacheManager,
|
||||||
|
LikeBridge? likeBridge,
|
||||||
}) {
|
}) {
|
||||||
_baseUrl = baseUrl;
|
_baseUrl = baseUrl;
|
||||||
_token = token;
|
_token = token;
|
||||||
if (coverCache != null) _coverCache = coverCache;
|
if (coverCache != null) _coverCache = coverCache;
|
||||||
|
if (likeBridge != null) _likeBridge = likeBridge;
|
||||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,12 +304,30 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
final extras = <String, dynamic>{};
|
final extras = <String, dynamic>{};
|
||||||
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
||||||
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
||||||
|
// Sync-peek the album cover cache so warm-cache tracks broadcast
|
||||||
|
// with artUri populated on the first frame. External media
|
||||||
|
// controllers (Android Wear, Bluetooth dashes, Auto, lock screen)
|
||||||
|
// can only render the cover bytes that audio_service hands them
|
||||||
|
// at MediaItem broadcast time; the later async _loadArtForCurrent
|
||||||
|
// Item path repopulates for cold-cache tracks. Without this seed,
|
||||||
|
// every track change starts with a generic icon on the watch and
|
||||||
|
// only gets the real cover after one or two seconds.
|
||||||
|
final coverPath = (t.albumId.isNotEmpty && _coverCache != null)
|
||||||
|
? _coverCache!.peekCached(t.albumId)
|
||||||
|
: null;
|
||||||
|
// MediaItem.rating reflects the user's like state on this track
|
||||||
|
// so external surfaces (Wear's heart button, lock-screen
|
||||||
|
// favorites) render the right filled/outlined icon. Updated on
|
||||||
|
// every track change via _likeBridge.isTrackLiked.
|
||||||
|
final liked = _likeBridge?.isTrackLiked(t.id) ?? false;
|
||||||
return MediaItem(
|
return MediaItem(
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artistName,
|
artist: t.artistName,
|
||||||
album: t.albumTitle,
|
album: t.albumTitle,
|
||||||
duration: Duration(seconds: t.durationSec),
|
duration: Duration(seconds: t.durationSec),
|
||||||
|
artUri: coverPath != null ? Uri.file(coverPath) : null,
|
||||||
|
rating: Rating.newHeartRating(liked),
|
||||||
extras: extras.isEmpty ? null : extras,
|
extras: extras.isEmpty ? null : extras,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -391,6 +412,58 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
@override
|
@override
|
||||||
Future<void> skipToPrevious() => _player.seekToPrevious();
|
Future<void> skipToPrevious() => _player.seekToPrevious();
|
||||||
|
|
||||||
|
/// Stops playback and dismisses the system notification. BaseAudio
|
||||||
|
/// Handler's default just sets the processing state to idle without
|
||||||
|
/// touching the player, so the just_audio engine kept its audio
|
||||||
|
/// session and external surfaces (Wear, Auto, BT) saw a "paused
|
||||||
|
/// forever" rather than a clean stop. Halting the player releases
|
||||||
|
/// the session and lets super.stop() tear down the foreground
|
||||||
|
/// notification.
|
||||||
|
@override
|
||||||
|
Future<void> stop() async {
|
||||||
|
await _player.stop();
|
||||||
|
await super.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heart rating from external surfaces (Wear's favorite button,
|
||||||
|
/// lock-screen like) → LikesController.toggle(track). We only
|
||||||
|
/// route through the bridge when the rating actually flips relative
|
||||||
|
/// to the current state, so repeated taps from a flaky controller
|
||||||
|
/// don't ping-pong the like.
|
||||||
|
@override
|
||||||
|
Future<void> setRating(Rating rating, [Map<String, dynamic>? extras]) async {
|
||||||
|
final media = mediaItem.value;
|
||||||
|
final bridge = _likeBridge;
|
||||||
|
if (media == null || bridge == null) return;
|
||||||
|
final currentlyLiked = bridge.isTrackLiked(media.id);
|
||||||
|
final wantLiked = rating.hasHeart();
|
||||||
|
if (currentlyLiked == wantLiked) return;
|
||||||
|
try {
|
||||||
|
await bridge.toggleTrackLike(media.id);
|
||||||
|
} catch (_) {
|
||||||
|
// LikesController already rolls back on REST failure; nothing
|
||||||
|
// to do here beyond letting the broadcast skip.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Re-emit so the watch's heart icon updates immediately.
|
||||||
|
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(wantLiked)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-emits the current mediaItem with a fresh rating pulled from
|
||||||
|
/// the LikeBridge. Called by PlayerActions on likedIdsProvider
|
||||||
|
/// changes so the watch / lock-screen heart icon updates when the
|
||||||
|
/// user toggles a like from TrackRow, the kebab menu, or another
|
||||||
|
/// device's playback (SSE-routed). No-op if no track is playing
|
||||||
|
/// or the like state didn't change.
|
||||||
|
void refreshCurrentRating() {
|
||||||
|
final media = mediaItem.value;
|
||||||
|
final bridge = _likeBridge;
|
||||||
|
if (media == null || bridge == null) return;
|
||||||
|
final liked = bridge.isTrackLiked(media.id);
|
||||||
|
if (media.rating?.hasHeart() == liked) return;
|
||||||
|
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked)));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||||
await _player
|
await _player
|
||||||
@@ -425,20 +498,33 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
MediaControl.skipToPrevious,
|
MediaControl.skipToPrevious,
|
||||||
if (playing) MediaControl.pause else MediaControl.play,
|
if (playing) MediaControl.pause else MediaControl.play,
|
||||||
MediaControl.skipToNext,
|
MediaControl.skipToNext,
|
||||||
|
MediaControl.stop,
|
||||||
],
|
],
|
||||||
// androidCompactActionIndices tells the system which controls
|
// androidCompactActionIndices tells the system which controls
|
||||||
// appear in the collapsed/lock-screen view. Without this, some
|
// appear in the collapsed/lock-screen view. Without this, some
|
||||||
// Android versions render the player without working buttons.
|
// Android versions render the player without working buttons.
|
||||||
|
// Keep this at 3 actions (prev/play-pause/next) — stop lives
|
||||||
|
// in the expanded view only.
|
||||||
androidCompactActionIndices: const [0, 1, 2],
|
androidCompactActionIndices: const [0, 1, 2],
|
||||||
// systemActions enumerates which actions the system can invoke
|
// systemActions enumerates which actions the system can invoke
|
||||||
// on us — without play/pause/skip in here, taps on lock-screen
|
// on us. Anything not listed here doesn't route back to the
|
||||||
// controls don't route back to the handler on Android 13+.
|
// handler from external surfaces (Wear, Auto, Bluetooth, lock
|
||||||
|
// screen) — Android 13+ silently drops those events. Keep this
|
||||||
|
// in sync with the override methods so each implemented action
|
||||||
|
// is actually advertised.
|
||||||
systemActions: const {
|
systemActions: const {
|
||||||
MediaAction.play,
|
MediaAction.play,
|
||||||
MediaAction.pause,
|
MediaAction.pause,
|
||||||
|
MediaAction.stop,
|
||||||
MediaAction.skipToNext,
|
MediaAction.skipToNext,
|
||||||
MediaAction.skipToPrevious,
|
MediaAction.skipToPrevious,
|
||||||
|
MediaAction.skipToQueueItem,
|
||||||
MediaAction.seek,
|
MediaAction.seek,
|
||||||
|
MediaAction.setShuffleMode,
|
||||||
|
MediaAction.setRepeatMode,
|
||||||
|
// Heart rating — Wear and lock screen render this as a like
|
||||||
|
// button. Routed to LikesController via _likeBridge.
|
||||||
|
MediaAction.setRating,
|
||||||
},
|
},
|
||||||
processingState: switch (_player.processingState) {
|
processingState: switch (_player.processingState) {
|
||||||
ProcessingState.idle => AudioProcessingState.idle,
|
ProcessingState.idle => AudioProcessingState.idle,
|
||||||
@@ -463,3 +549,27 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adapter that lets the audio handler call into the app's
|
||||||
|
/// LikesController without depending on Riverpod directly. Constructed
|
||||||
|
/// in PlayerActions where ref is available; consumed inside the audio
|
||||||
|
/// handler's setRating override and MediaItem builder to keep external
|
||||||
|
/// media controllers (Wear, lock screen, Auto) in sync with the
|
||||||
|
/// likedIds drift cache.
|
||||||
|
class LikeBridge {
|
||||||
|
const LikeBridge({
|
||||||
|
required this.toggleTrackLike,
|
||||||
|
required this.isTrackLiked,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Flip the like state for [trackId]. Returns a Future that resolves
|
||||||
|
/// once the underlying LikesController has both updated drift
|
||||||
|
/// optimistically and rolled the change through the REST API
|
||||||
|
/// (errors result in drift rollback inside LikesController).
|
||||||
|
final Future<void> Function(String trackId) toggleTrackLike;
|
||||||
|
|
||||||
|
/// Read the current like state for [trackId] from the drift-backed
|
||||||
|
/// likedIdsProvider. Synchronous because the audio handler needs
|
||||||
|
/// to populate MediaItem.rating on the broadcast hot path.
|
||||||
|
final bool Function(String trackId) isTrackLiked;
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,39 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
/// a track whose cover hadn't finished decoding yet.
|
/// a track whose cover hadn't finished decoding yet.
|
||||||
String? _pendingPreloadId;
|
String? _pendingPreloadId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Seed displayed state from the current mediaItem if a track is
|
||||||
|
// already playing when the full player is opened. ref.listen
|
||||||
|
// below only fires on CHANGES after subscription, so without
|
||||||
|
// this seed the screen sits on "Nothing playing." even though
|
||||||
|
// the mini bar shows a live track — the listener never gets a
|
||||||
|
// mediaItem emission because the stream's current value hasn't
|
||||||
|
// changed.
|
||||||
|
final current = ref.read(mediaItemProvider).value;
|
||||||
|
if (current == null) return;
|
||||||
|
_displayedMedia = current;
|
||||||
|
// Best-effort synchronous color seed: if albumColorProvider has
|
||||||
|
// already extracted this album's dominant color earlier in the
|
||||||
|
// session, pull it now so the backdrop renders correctly on
|
||||||
|
// first frame. Otherwise the post-frame _scheduleSwap below
|
||||||
|
// resolves it asynchronously and AnimatedContainer tweens.
|
||||||
|
final albumId = current.extras?['album_id'] as String?;
|
||||||
|
if (albumId != null && albumId.isNotEmpty) {
|
||||||
|
_displayedDominant =
|
||||||
|
ref.read(albumColorProvider(albumId)).asData?.value;
|
||||||
|
}
|
||||||
|
// Kick the preload pipeline post-frame so the cover bytes are
|
||||||
|
// decoded + the dominant color is resolved even when the user
|
||||||
|
// opens the player to a track that hasn't yet flowed through
|
||||||
|
// ref.listen.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_scheduleSwap(current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Preload the new track's cover bytes + dominant color, then
|
/// Preload the new track's cover bytes + dominant color, then
|
||||||
/// atomically flip _displayedMedia / _displayedDominant. Until both
|
/// atomically flip _displayedMedia / _displayedDominant. Until both
|
||||||
/// resolve, the screen continues to render the previous track —
|
/// resolve, the screen continues to render the previous track —
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/likes.dart' show LikeKind;
|
||||||
import '../api/endpoints/radio.dart';
|
import '../api/endpoints/radio.dart';
|
||||||
import '../auth/auth_provider.dart';
|
import '../auth/auth_provider.dart';
|
||||||
import '../cache/audio_cache_manager.dart';
|
import '../cache/audio_cache_manager.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import '../likes/likes_provider.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import 'album_cover_cache.dart';
|
import 'album_cover_cache.dart';
|
||||||
import 'audio_handler.dart';
|
import 'audio_handler.dart';
|
||||||
@@ -49,7 +51,18 @@ final positionProvider = StreamProvider<Duration>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
class PlayerActions {
|
class PlayerActions {
|
||||||
PlayerActions(this._ref);
|
PlayerActions(this._ref) {
|
||||||
|
// Keep MediaItem.rating in sync with the drift-backed likedIds
|
||||||
|
// cache so external media surfaces (Wear's heart button, lock-
|
||||||
|
// screen favorite, Auto's like icon) reflect the current state
|
||||||
|
// even when the user toggles a like from somewhere other than
|
||||||
|
// the watch itself — e.g. tapping the heart on a TrackRow, the
|
||||||
|
// kebab menu, or another logged-in device propagating via SSE.
|
||||||
|
_ref.listen<AsyncValue<LikedIds>>(likedIdsProvider, (_, next) {
|
||||||
|
if (next.value == null) return;
|
||||||
|
_ref.read(audioHandlerProvider).refreshCurrentRating();
|
||||||
|
});
|
||||||
|
}
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
|
||||||
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||||
@@ -63,11 +76,28 @@ class PlayerActions {
|
|||||||
token: token,
|
token: token,
|
||||||
coverCache: cache,
|
coverCache: cache,
|
||||||
audioCacheManager: audioCache,
|
audioCacheManager: audioCache,
|
||||||
|
likeBridge: _buildLikeBridge(),
|
||||||
);
|
);
|
||||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||||
await h.play();
|
await h.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the adapter the audio handler uses to read + flip the
|
||||||
|
/// current track's like state in response to external media-
|
||||||
|
/// controller events (Wear's heart button, lock-screen favorite).
|
||||||
|
/// Constructed here because the audio handler is initialized in
|
||||||
|
/// main.dart before the ProviderScope exists; passing the bridge
|
||||||
|
/// in via configure() keeps the handler Riverpod-agnostic while
|
||||||
|
/// still letting it call into LikesController + likedIdsProvider.
|
||||||
|
LikeBridge _buildLikeBridge() {
|
||||||
|
return LikeBridge(
|
||||||
|
toggleTrackLike: (id) =>
|
||||||
|
_ref.read(likesControllerProvider).toggle(LikeKind.track, id),
|
||||||
|
isTrackLiked: (id) =>
|
||||||
|
_ref.read(likedIdsProvider).value?.has(LikeKind.track, id) ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> playNext(TrackRef track) async {
|
Future<void> playNext(TrackRef track) async {
|
||||||
final h = _ref.read(audioHandlerProvider);
|
final h = _ref.read(audioHandlerProvider);
|
||||||
await h.playNext(track);
|
await h.playNext(track);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: minstrel
|
name: minstrel
|
||||||
description: Minstrel mobile client
|
description: Minstrel mobile client
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2026.5.13+3
|
version: 2026.5.13+4
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.5.0 <4.0.0'
|
sdk: '>=3.5.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user