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,