Skip to content

Commit bba91f3

Browse files
authored
feat: support Android media resumption (#851)
1 parent 333ee80 commit bba91f3

1 file changed

Lines changed: 196 additions & 5 deletions

File tree

lib/services/audio_service.dart

Lines changed: 196 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ class MusifyAudioHandler extends BaseAudioHandler {
9797
static const Duration _errorRetryDelay = Duration(seconds: 2);
9898
static const Duration _songTransitionTimeout = Duration(seconds: 30);
9999
static const Duration _debounceInterval = Duration(milliseconds: 150);
100+
static const String _recentMediaIdPrefix = 'recent:';
100101

101102
int _activePreloadCount = 0;
102103
final Set<String> _preloadingYtIds = <String>{};
@@ -752,7 +753,11 @@ class MusifyAudioHandler extends BaseAudioHandler {
752753
await _playFromQueue(0);
753754
}
754755
} catch (e, stackTrace) {
755-
logger.log('Error inserting recommended song', error: e, stackTrace: stackTrace);
756+
logger.log(
757+
'Error inserting recommended song',
758+
error: e,
759+
stackTrace: stackTrace,
760+
);
756761
}
757762
}
758763

@@ -1256,6 +1261,183 @@ class MusifyAudioHandler extends BaseAudioHandler {
12561261

12571262
bool get hasPrevious => _currentQueueIndex > 0 || _historyList.isNotEmpty;
12581263

1264+
String _recentMediaId(String ytid) => '$_recentMediaIdPrefix$ytid';
1265+
1266+
String? _ytidFromMediaId(String mediaId) {
1267+
if (mediaId.startsWith(_recentMediaIdPrefix)) {
1268+
return mediaId.substring(_recentMediaIdPrefix.length);
1269+
}
1270+
return mediaId.isEmpty ? null : mediaId;
1271+
}
1272+
1273+
String? _songYtid(Map song) {
1274+
final ytid = song['ytid']?.toString();
1275+
return ytid == null || ytid.isEmpty ? null : ytid;
1276+
}
1277+
1278+
Map? _firstPlayableSong(Iterable songs) {
1279+
for (final song in songs.whereType<Map>()) {
1280+
if (_songYtid(song) != null) {
1281+
return song;
1282+
}
1283+
}
1284+
return null;
1285+
}
1286+
1287+
Map? _findSongInList(Iterable songs, String ytid) {
1288+
for (final song in songs.whereType<Map>()) {
1289+
if (_songYtid(song) == ytid) {
1290+
return song;
1291+
}
1292+
}
1293+
return null;
1294+
}
1295+
1296+
Map? _findSongByYtid(String? ytid) {
1297+
if (ytid == null || ytid.isEmpty) return null;
1298+
1299+
final activeSong = currentSong;
1300+
if (activeSong?['ytid']?.toString() == ytid) {
1301+
return activeSong;
1302+
}
1303+
1304+
for (final source in [
1305+
_queueList,
1306+
userRecentlyPlayed,
1307+
userOfflineSongs,
1308+
userLikedSongsList,
1309+
]) {
1310+
final song = _findSongInList(source, ytid);
1311+
if (song != null) return song;
1312+
}
1313+
1314+
return null;
1315+
}
1316+
1317+
Map? _latestResumableSong() {
1318+
final activeSong = currentSong;
1319+
if (activeSong != null && _songYtid(activeSong) != null) {
1320+
return activeSong;
1321+
}
1322+
1323+
final activeMediaItem = mediaItem.valueOrNull;
1324+
final activeYtid = activeMediaItem?.extras?['ytid']?.toString();
1325+
final activeMediaSong = _findSongByYtid(activeYtid);
1326+
if (activeMediaSong != null) return activeMediaSong;
1327+
if (activeYtid != null &&
1328+
activeYtid.isNotEmpty &&
1329+
activeMediaItem != null) {
1330+
return mediaItemToMap(activeMediaItem);
1331+
}
1332+
1333+
return _firstPlayableSong(userRecentlyPlayed) ??
1334+
_firstPlayableSong(userOfflineSongs) ??
1335+
_firstPlayableSong(userLikedSongsList);
1336+
}
1337+
1338+
Map<String, dynamic>? _normaliseResumableSong(Map song) {
1339+
final ytid = _songYtid(song);
1340+
if (ytid == null) return null;
1341+
1342+
final normalised = cloneMap(song);
1343+
normalised['id'] = ytid;
1344+
normalised['ytid'] = ytid;
1345+
normalised['highResImage'] ??=
1346+
normalised['image'] ?? normalised['lowResImage'] ?? '';
1347+
normalised['lowResImage'] ??= normalised['highResImage'];
1348+
normalised['isLive'] ??= false;
1349+
return normalised;
1350+
}
1351+
1352+
MediaItem? _mediaItemForResumption(Map song) {
1353+
final normalisedSong = _normaliseResumableSong(song);
1354+
if (normalisedSong == null) return null;
1355+
1356+
final ytid = normalisedSong['ytid'].toString();
1357+
final artist = normalisedSong['artist']?.toString().trim() ?? '';
1358+
return mapToMediaItem(normalisedSong).copyWith(
1359+
id: _recentMediaId(ytid),
1360+
displayTitle: normalisedSong['title']?.toString(),
1361+
displaySubtitle: artist.isEmpty ? 'Musify' : artist,
1362+
);
1363+
}
1364+
1365+
Future<void> _playResumableSong(Map song) async {
1366+
final normalisedSong = _normaliseResumableSong(song);
1367+
if (normalisedSong == null) return;
1368+
1369+
await playPlaylistSong(
1370+
playlist: {
1371+
'title': 'Musify',
1372+
'source': 'system-recent',
1373+
'list': [normalisedSong],
1374+
},
1375+
songIndex: 0,
1376+
);
1377+
}
1378+
1379+
@override
1380+
Future<List<MediaItem>> getChildren(
1381+
String parentMediaId, [
1382+
Map<String, dynamic>? options,
1383+
]) async {
1384+
if (parentMediaId != AudioService.recentRootId &&
1385+
parentMediaId != AudioService.browsableRootId) {
1386+
return [];
1387+
}
1388+
1389+
final recentSong = _latestResumableSong();
1390+
final recentItem = recentSong == null
1391+
? null
1392+
: _mediaItemForResumption(recentSong);
1393+
return recentItem == null ? [] : [recentItem];
1394+
}
1395+
1396+
@override
1397+
Future<MediaItem?> getMediaItem(String mediaId) async {
1398+
final song = _findSongByYtid(_ytidFromMediaId(mediaId));
1399+
return song == null ? null : _mediaItemForResumption(song);
1400+
}
1401+
1402+
@override
1403+
Future<void> prepareFromMediaId(
1404+
String mediaId, [
1405+
Map<String, dynamic>? extras,
1406+
]) async {
1407+
final item = await getMediaItem(mediaId);
1408+
if (item == null) return;
1409+
1410+
mediaItem.add(item);
1411+
queue.add([item]);
1412+
playbackState.add(
1413+
PlaybackState(
1414+
controls: _pausedControls,
1415+
systemActions: const {
1416+
MediaAction.seek,
1417+
MediaAction.seekForward,
1418+
MediaAction.seekBackward,
1419+
},
1420+
androidCompactActionIndices: const [0, 1, 3],
1421+
processingState: AudioProcessingState.ready,
1422+
queueIndex: 0,
1423+
updateTime: DateTime.now(),
1424+
),
1425+
);
1426+
}
1427+
1428+
@override
1429+
Future<void> playFromMediaId(
1430+
String mediaId, [
1431+
Map<String, dynamic>? extras,
1432+
]) async {
1433+
final song = _findSongByYtid(_ytidFromMediaId(mediaId));
1434+
if (song == null) {
1435+
logger.log('No resumable song found for media id: $mediaId');
1436+
return;
1437+
}
1438+
await _playResumableSong(song);
1439+
}
1440+
12591441
@override
12601442
Future<void> onTaskRemoved() async {
12611443
try {
@@ -1271,6 +1453,13 @@ class MusifyAudioHandler extends BaseAudioHandler {
12711453
@override
12721454
Future<void> play() async {
12731455
try {
1456+
if (audioPlayer.audioSource == null) {
1457+
final recentSong = _latestResumableSong();
1458+
if (recentSong != null) {
1459+
await _playResumableSong(recentSong);
1460+
return;
1461+
}
1462+
}
12741463
await audioPlayer.play();
12751464
} catch (e, stackTrace) {
12761465
logger.log('Error in play()', error: e, stackTrace: stackTrace);
@@ -1307,10 +1496,12 @@ class MusifyAudioHandler extends BaseAudioHandler {
13071496
/// Returns unplayed manually added songs after the current queue index.
13081497
List<Map> _getUnplayedManualSongs() {
13091498
return _queueList
1310-
.skip(_currentQueueIndex >= 0 ? _currentQueueIndex + 1 : 0)
1311-
.where((song) =>
1312-
song['isManuallyAdded'] == true && song['isAutoPicked'] != true)
1313-
.toList();
1499+
.skip(_currentQueueIndex >= 0 ? _currentQueueIndex + 1 : 0)
1500+
.where(
1501+
(song) =>
1502+
song['isManuallyAdded'] == true && song['isAutoPicked'] != true,
1503+
)
1504+
.toList();
13141505
}
13151506

13161507
void _resetPreloadingState() {

0 commit comments

Comments
 (0)