This commit is contained in:
gituser-vibes 2026-06-24 14:54:52 -07:00 committed by GitHub
commit 137bc17e08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 436 additions and 2 deletions

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04z" />
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04z" />
<!-- "on" indicator dot below the glyph -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10.6,21 a1.4,1.4 0 1,0 2.8,0 a1.4,1.4 0 1,0 -2.8,0z" />
</vector>

View File

@ -23,7 +23,7 @@ class AudioServices with WidgetsBindingObserver {
) async { ) async {
final mobile = kIsMobile || kIsMacOS || kIsLinux final mobile = kIsMobile || kIsMacOS || kIsLinux
? await AudioService.init( ? await AudioService.init(
builder: () => MobileAudioService(playback), builder: () => MobileAudioService(ref, playback),
config: AudioServiceConfig( config: AudioServiceConfig(
androidNotificationChannelId: switch (( androidNotificationChannelId: switch ((
kIsLinux, kIsLinux,

View File

@ -3,9 +3,13 @@ import 'dart:io';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.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/audio_player.dart';
import 'package:spotube/provider/audio_player/state.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/audio_player/audio_player.dart';
import 'package:spotube/services/metadata/metadata.dart';
import 'package:media_kit/media_kit.dart' hide Track; import 'package:media_kit/media_kit.dart' hide Track;
import 'package:spotube/services/audio_player/playback_state.dart'; import 'package:spotube/services/audio_player/playback_state.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
@ -13,12 +17,42 @@ import 'package:spotube/utils/platform.dart';
class MobileAudioService extends BaseAudioHandler { class MobileAudioService extends BaseAudioHandler {
AudioSession? session; AudioSession? session;
final Ref ref;
final AudioPlayerNotifier audioPlayerNotifier; 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/<playlistId>`.
static const String playlistMediaIdPrefix = 'spotube://playlist/';
/// Playable item that plays a whole collection. Id is
/// `spotube://playall/<collectionId>` where collectionId is [likedMediaId]
/// or a `spotube://playlist/<id>`.
static const String _playAllPrefix = 'spotube://playall/';
/// Playable item that plays a collection starting at a specific track. Id is
/// `spotube://track/<collectionId>/<trackId>`.
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/<sectionId>`.
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 // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
AudioPlayerState get playlist => audioPlayerNotifier.state; AudioPlayerState get playlist => audioPlayerNotifier.state;
MobileAudioService(this.audioPlayerNotifier) { MobileAudioService(this.ref, this.audioPlayerNotifier) {
AudioSession.instance.then((s) { AudioSession.instance.then((s) {
session = s; session = s;
session?.configure(const AudioSessionConfiguration.music()); session?.configure(const AudioSessionConfiguration.music());
@ -95,6 +129,23 @@ class MobileAudioService extends BaseAudioHandler {
audioPlayer.setShuffle(shuffleMode == AudioServiceShuffleMode.all); audioPlayer.setShuffle(shuffleMode == AudioServiceShuffleMode.all);
} }
@override
Future<dynamic> customAction(
String name, [
Map<String, dynamic>? 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 @override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async { Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
super.setRepeatMode(repeatMode); super.setRepeatMode(repeatMode);
@ -130,6 +181,357 @@ class MobileAudioService extends BaseAudioHandler {
if (kIsAndroid) exit(0); 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<List<MediaItem>> getChildren(
String parentMediaId, [
Map<String, dynamic>? 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<List<MediaItem>> _rootChildren() async {
final items = <MediaItem>[];
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<List<MediaItem>> _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 = <MediaItem>[];
for (final section in sections.items) {
// Skip sections whose preview has no playlists (e.g. artist-only rows).
if (section.items.whereType<SpotubeSimplePlaylistObject>().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<List<MediaItem>> _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 = <String>{};
final items = <MediaItem>[];
for (final playlist
in result.items.whereType<SpotubeSimplePlaylistObject>()) {
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<List<MediaItem>> _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>[
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/<id>`) to
/// its tracks.
Future<List<SpotubeFullTrackObject>> _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<SpotubeImageObject>? images,
ImagePlaceholder placeholder,
) {
final url = images.asUrlString(placeholder: placeholder);
return url.startsWith('http') ? Uri.parse(url) : null;
}
@override
Future<void> playFromMediaId(
String mediaId, [
Map<String, dynamic>? 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<SpotubeTrackObject>(),
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<List<SpotubeFullTrackObject>> _fetchAllTracks(
Future<SpotubePaginationResponseObject<SpotubeFullTrackObject>> Function(
int offset,
int limit,
) fetch, {
int pageSize = 50,
int maxTracks = 500,
}) async {
final tracks = <SpotubeFullTrackObject>[];
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<T> _withAuthRetry<T>(
Future<T> 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<PlaybackState> _transformEvent() async { Future<PlaybackState> _transformEvent() async {
try { try {
return PlaybackState( return PlaybackState(
@ -138,6 +540,16 @@ class MobileAudioService extends BaseAudioHandler {
audioPlayer.isPlaying ? MediaControl.pause : MediaControl.play, audioPlayer.isPlaying ? MediaControl.pause : MediaControl.play,
MediaControl.skipToNext, MediaControl.skipToNext,
MediaControl.stop, 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: { systemActions: {
MediaAction.seek, MediaAction.seek,