From 95d2c72a18443facaa29b37b101ec0f4931c7d93 Mon Sep 17 00:00:00 2001 From: gituser-vibes <296671358+gituser-vibes@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:51:01 -0700 Subject: [PATCH] feat(android-auto): browse & play playlists, Liked Songs and Discover Weekly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the audio_service media-browser hooks so Android Auto (and other media browsers) can browse the library and start playback: - getChildren exposes Liked Songs, a "Browse" folder (the home / "Made for you" hub — including Discover Weekly, fetched via browse.sectionItems since the section preview omits it), and the user's saved playlists. - Collections are browsable into their track list with a "Play all" entry; tapping a track plays the collection from that point. - playFromMediaId resolves the selection and plays through the existing AudioPlayerNotifier.load pipeline. - Add an explicit shuffle MediaControl + custom action (Android Auto does not reliably render a toggle from the standard ACTION_SET_SHUFFLE_MODE alone). - Retry auth-sensitive requests: the stored token is often stale on cold start and the first call 401s before the plugin refreshes it. - Browse-item artwork uses http(s) URLs only — the media-browser process cannot read app-private file:// paths. The MediaBrowserService and automotive_app_desc.xml were already declared in the manifest, so no native changes are required. --- .../app/src/main/res/drawable/ic_shuffle.xml | 9 + .../src/main/res/drawable/ic_shuffle_on.xml | 13 + .../audio_services/audio_services.dart | 2 +- .../audio_services/mobile_audio_service.dart | 414 +++++++++++++++++- 4 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_shuffle.xml create mode 100644 android/app/src/main/res/drawable/ic_shuffle_on.xml diff --git a/android/app/src/main/res/drawable/ic_shuffle.xml b/android/app/src/main/res/drawable/ic_shuffle.xml new file mode 100644 index 000000000..e63193870 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shuffle.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_shuffle_on.xml b/android/app/src/main/res/drawable/ic_shuffle_on.xml new file mode 100644 index 000000000..75f8db632 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shuffle_on.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index c511da61b..57254bf04 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -23,7 +23,7 @@ class AudioServices with WidgetsBindingObserver { ) async { final mobile = kIsMobile || kIsMacOS || kIsLinux ? await AudioService.init( - builder: () => MobileAudioService(playback), + builder: () => MobileAudioService(ref, playback), config: AudioServiceConfig( androidNotificationChannelId: switch (( kIsLinux, diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 16a3618ee..491b16229 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -3,9 +3,13 @@ import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/state.dart'; +import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/metadata/metadata.dart'; import 'package:media_kit/media_kit.dart' hide Track; import 'package:spotube/services/audio_player/playback_state.dart'; import 'package:spotube/services/logger/logger.dart'; @@ -13,12 +17,42 @@ import 'package:spotube/utils/platform.dart'; class MobileAudioService extends BaseAudioHandler { AudioSession? session; + final Ref ref; final AudioPlayerNotifier audioPlayerNotifier; + /// Media id of the synthetic "Liked Songs" browsable item exposed to + /// Android Auto / system media browsers. + static const String likedMediaId = 'spotube://liked'; + + /// Prefix for playlist browsable items. The full id is + /// `spotube://playlist/`. + static const String playlistMediaIdPrefix = 'spotube://playlist/'; + + /// Playable item that plays a whole collection. Id is + /// `spotube://playall/` where collectionId is [likedMediaId] + /// or a `spotube://playlist/`. + static const String _playAllPrefix = 'spotube://playall/'; + + /// Playable item that plays a collection starting at a specific track. Id is + /// `spotube://track//`. + static const String _trackActionPrefix = 'spotube://track/'; + + /// Browsable folder exposing the plugin's browse/home sections (e.g. the + /// "Made for you" hub with Discover Weekly), which aren't returned by + /// savedPlaylists. + static const String _browseMediaId = 'spotube://browse'; + + /// Browsable folder for a single browse section. Id is + /// `spotube://section/`. + static const String _sectionPrefix = 'spotube://section/'; + + /// Name of the custom "shuffle" media control action. + static const String _shuffleActionName = 'shuffle'; + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member AudioPlayerState get playlist => audioPlayerNotifier.state; - MobileAudioService(this.audioPlayerNotifier) { + MobileAudioService(this.ref, this.audioPlayerNotifier) { AudioSession.instance.then((s) { session = s; session?.configure(const AudioSessionConfiguration.music()); @@ -95,6 +129,23 @@ class MobileAudioService extends BaseAudioHandler { audioPlayer.setShuffle(shuffleMode == AudioServiceShuffleMode.all); } + @override + Future customAction( + String name, [ + Map? extras, + ]) async { + if (name == _shuffleActionName) { + final next = !audioPlayer.isShuffled; + await setShuffleMode( + next ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, + ); + // Refresh playback state so the control's icon reflects the new state. + playbackState.add(await _transformEvent()); + return null; + } + return super.customAction(name, extras); + } + @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { super.setRepeatMode(repeatMode); @@ -130,6 +181,357 @@ class MobileAudioService extends BaseAudioHandler { if (kIsAndroid) exit(0); } + // =========================================================================== + // Media browsing (Android Auto / system media browser). + // + // The root level exposes a "Liked Songs" entry plus the user's saved + // playlists (which, for most accounts, includes Spotify's auto-followed + // mixes such as "Discover Weekly" and "Release Radar"). Each entry is + // playable: selecting it in Android Auto triggers [playFromMediaId], which + // resolves the tracks and starts playback through the normal pipeline. + // =========================================================================== + + @override + Future> getChildren( + String parentMediaId, [ + Map? options, + ]) async { + if (parentMediaId == AudioService.browsableRootId) { + return _rootChildren(); + } + if (parentMediaId == _browseMediaId) { + return _browseChildren(); + } + if (parentMediaId.startsWith(_sectionPrefix)) { + return _sectionChildren(parentMediaId.substring(_sectionPrefix.length)); + } + if (parentMediaId == likedMediaId || + parentMediaId.startsWith(playlistMediaIdPrefix)) { + return _collectionChildren(parentMediaId); + } + return const []; + } + + /// Top level: "Liked Songs" + the user's saved playlists, all browsable + /// (tap to drill into the track list). + Future> _rootChildren() async { + final items = []; + + try { + final plugin = await ref + .read(metadataPluginProvider.future) + .timeout(const Duration(seconds: 20)); + + // Liked Songs uses the most recently liked track's cover as its art. + // (A bundled asset / private-cache file:// URI isn't readable by the + // Android Auto process — only http(s) URLs are.) + Uri? likedArt; + if (plugin != null) { + try { + final firstLiked = await _withAuthRetry( + () => plugin.user.savedTracks(offset: 0, limit: 1), + ); + likedArt = _httpArtUri( + firstLiked.items.firstOrNull?.album.images, + ImagePlaceholder.albumArt, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } + } + + items.add( + MediaItem( + id: likedMediaId, + title: 'Liked Songs', + artUri: likedArt, + playable: false, + ), + ); + + if (plugin == null) return items; + + // "Made for you" / Discover Weekly etc. live in the browse hub, not in + // savedPlaylists. + items.add( + const MediaItem( + id: _browseMediaId, + title: 'Browse', + playable: false, + ), + ); + + final playlists = await _withAuthRetry( + () => plugin.user.savedPlaylists(offset: 0, limit: 50), + ); + for (final playlist in playlists.items) { + items.add( + MediaItem( + id: '$playlistMediaIdPrefix${playlist.id}', + title: playlist.name, + artUri: _httpArtUri( + playlist.images, + ImagePlaceholder.collection, + ), + playable: false, + ), + ); + } + } catch (e, stack) { + // Likely not authenticated yet; still expose the Liked Songs entry. + AppLogger.reportError(e, stack); + if (items.isEmpty) { + items.add( + const MediaItem( + id: likedMediaId, + title: 'Liked Songs', + playable: false, + ), + ); + } + } + + return items; + } + + /// Children of the "Browse" folder: the plugin's browse/home sections that + /// contain playlists (Made for you, Your top mixes, etc.), each a browsable + /// folder. The full playlist list (including Discover Weekly, which the + /// section preview omits) is fetched on demand in [_sectionChildren]. + Future> _browseChildren() async { + try { + final plugin = await ref + .read(metadataPluginProvider.future) + .timeout(const Duration(seconds: 20)); + if (plugin == null) return const []; + + final sections = await _withAuthRetry( + () => plugin.browse.sections(offset: 0, limit: 20), + ); + + final items = []; + for (final section in sections.items) { + // Skip sections whose preview has no playlists (e.g. artist-only rows). + if (section.items.whereType().isEmpty) { + continue; + } + items.add( + MediaItem( + id: '$_sectionPrefix${section.id}', + title: section.title, + playable: false, + ), + ); + } + return items; + } catch (e, stack) { + AppLogger.reportError(e, stack); + return const []; + } + } + + /// Children of a browse section: its full playlist list (this is where + /// Discover Weekly lives — it is absent from the section preview). + Future> _sectionChildren(String sectionId) async { + try { + final plugin = await ref + .read(metadataPluginProvider.future) + .timeout(const Duration(seconds: 20)); + if (plugin == null) return const []; + + final result = await _withAuthRetry( + () => plugin.browse.sectionItems(sectionId, offset: 0, limit: 50), + ); + + final seen = {}; + final items = []; + for (final playlist + in result.items.whereType()) { + if (!seen.add(playlist.id)) continue; + items.add( + MediaItem( + id: '$playlistMediaIdPrefix${playlist.id}', + title: playlist.name, + artUri: _httpArtUri(playlist.images, ImagePlaceholder.collection), + playable: false, + ), + ); + } + return items; + } catch (e, stack) { + AppLogger.reportError(e, stack); + return const []; + } + } + + /// Children of a collection (Liked Songs or a playlist): a "Play all" entry + /// followed by the track list. Tapping a track plays the collection from + /// that track. + Future> _collectionChildren(String collectionId) async { + try { + final plugin = await ref + .read(metadataPluginProvider.future) + .timeout(const Duration(seconds: 20)); + if (plugin == null) return const []; + + final tracks = await _collectionTracks(plugin, collectionId); + + final items = [ + MediaItem( + id: '$_playAllPrefix$collectionId', + title: '▶ Play all', + playable: true, + ), + ]; + + for (final track in tracks) { + items.add( + MediaItem( + id: '$_trackActionPrefix$collectionId/${track.id}', + title: track.name, + artist: track.artists.asString(), + album: track.album.name, + duration: Duration(milliseconds: track.durationMs), + artUri: _httpArtUri(track.album.images, ImagePlaceholder.albumArt), + playable: true, + ), + ); + } + + return items; + } catch (e, stack) { + AppLogger.reportError(e, stack); + return const []; + } + } + + /// Resolves a collection id ([likedMediaId] or `spotube://playlist/`) to + /// its tracks. + Future> _collectionTracks( + MetadataPlugin plugin, + String collectionId, + ) { + if (collectionId == likedMediaId) { + return _fetchAllTracks( + (offset, limit) => plugin.user.savedTracks(offset: offset, limit: limit), + ); + } + if (collectionId.startsWith(playlistMediaIdPrefix)) { + final playlistId = collectionId.substring(playlistMediaIdPrefix.length); + return _fetchAllTracks( + (offset, limit) => + plugin.playlist.tracks(playlistId, offset: offset, limit: limit), + ); + } + return Future.value(const []); + } + + /// Returns an http(s) art URI for [images], or null. Media browsers run in a + /// different process and can only load network art — never bundled-asset + /// placeholder paths. + Uri? _httpArtUri( + List? images, + ImagePlaceholder placeholder, + ) { + final url = images.asUrlString(placeholder: placeholder); + return url.startsWith('http') ? Uri.parse(url) : null; + } + + @override + Future playFromMediaId( + String mediaId, [ + Map? extras, + ]) async { + try { + final plugin = await ref.read(metadataPluginProvider.future); + if (plugin == null) return; + + // Parse the media id into a collection to play and an optional track to + // start from. + String collectionId; + String? startTrackId; + if (mediaId.startsWith(_trackActionPrefix)) { + final rest = mediaId.substring(_trackActionPrefix.length); + final sep = rest.lastIndexOf('/'); + if (sep < 0) return; + collectionId = rest.substring(0, sep); + startTrackId = rest.substring(sep + 1); + } else if (mediaId.startsWith(_playAllPrefix)) { + collectionId = mediaId.substring(_playAllPrefix.length); + } else if (mediaId == likedMediaId || + mediaId.startsWith(playlistMediaIdPrefix)) { + // Fallback: a collection id played directly. + collectionId = mediaId; + } else { + return; + } + + final tracks = await _collectionTracks(plugin, collectionId); + if (tracks.isEmpty) return; + + var initialIndex = 0; + if (startTrackId != null) { + final idx = tracks.indexWhere((track) => track.id == startTrackId); + if (idx >= 0) initialIndex = idx; + } + + await audioPlayerNotifier.load( + tracks.cast(), + initialIndex: initialIndex, + autoPlay: true, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } + } + + /// Pages through a paginated track endpoint, accumulating up to [maxTracks] + /// tracks (bounded so very large libraries don't stall playback start). + Future> _fetchAllTracks( + Future> Function( + int offset, + int limit, + ) fetch, { + int pageSize = 50, + int maxTracks = 500, + }) async { + final tracks = []; + var offset = 0; + + while (tracks.length < maxTracks) { + final page = await _withAuthRetry(() => fetch(offset, pageSize)); + tracks.addAll(page.items); + if (!page.hasMore || page.items.isEmpty) break; + offset = page.nextOffset ?? (offset + page.items.length); + } + + return tracks; + } + + /// Runs [fn], retrying on failure. On a cold start the stored access token is + /// often stale, so the first authenticated request fails with 401; the plugin + /// refreshes the token in the background, so a retry succeeds. Each attempt is + /// time-bounded so a hung request can't stall the browse tree. + Future _withAuthRetry( + Future Function() fn, { + int attempts = 3, + Duration delay = const Duration(milliseconds: 700), + Duration timeout = const Duration(seconds: 15), + }) async { + Object? lastError; + StackTrace? lastStack; + for (var attempt = 0; attempt < attempts; attempt++) { + try { + return await fn().timeout(timeout); + } catch (e, stack) { + lastError = e; + lastStack = stack; + if (attempt < attempts - 1) await Future.delayed(delay); + } + } + Error.throwWithStackTrace(lastError!, lastStack!); + } + Future _transformEvent() async { try { return PlaybackState( @@ -138,6 +540,16 @@ class MobileAudioService extends BaseAudioHandler { audioPlayer.isPlaying ? MediaControl.pause : MediaControl.play, MediaControl.skipToNext, MediaControl.stop, + // Explicit shuffle toggle. audio_service always advertises the + // standard ACTION_SET_SHUFFLE_MODE, but Android Auto doesn't reliably + // render a toggle from it, so we expose a tappable custom control. + MediaControl.custom( + androidIcon: audioPlayer.isShuffled == true + ? 'drawable/ic_shuffle_on' + : 'drawable/ic_shuffle', + label: 'Shuffle', + name: _shuffleActionName, + ), ], systemActions: { MediaAction.seek,