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 {
|
) 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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user