mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Merge 95d2c72a18 into 69a310c78f
This commit is contained in:
commit
137bc17e08
9
android/app/src/main/res/drawable/ic_shuffle.xml
Normal file
9
android/app/src/main/res/drawable/ic_shuffle.xml
Normal 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>
|
||||
13
android/app/src/main/res/drawable/ic_shuffle_on.xml
Normal file
13
android/app/src/main/res/drawable/ic_shuffle_on.xml
Normal 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>
|
||||
@ -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,
|
||||
|
||||
@ -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/<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
|
||||
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<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
|
||||
Future<void> 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<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 {
|
||||
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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user