From 9c92e2529edeb6965e099167931ec48bc1e86789 Mon Sep 17 00:00:00 2001 From: Rahul Agarwal Date: Sat, 18 Jul 2026 18:26:18 +0530 Subject: [PATCH] fix: propagate player-discovered duration to OS media session Tracks opened from Home/Album/Liked Album pages can carry an unknown (zero) durationMs from the metadata source, which was forwarded verbatim to audio_service as Duration.zero. Android's media notification then rendered 0:00 / 0:00 with no working seek bar, and macOS Now Playing showed --:-- (issue #3077), even though the in-app player UI showed the correct duration via the player's durationStream. WindowsAudioService already forwards audioPlayer.durationStream to SMTC (setEndTime), but MobileAudioService (Android/macOS/Linux) never updated the active MediaItem after the player discovered the real duration. - Report an unknown (zero) model duration as null instead of Duration.zero so the OS treats it as unknown rather than 0:00. - Update the active MediaItem from audioPlayer.durationStream once the real duration is known, mirroring the existing Windows behaviour. Fixes #3077 Co-Authored-By: Claude Fable 5 --- .../audio_services/audio_services.dart | 20 ++- .../audio_services/mobile_audio_service.dart | 24 ++++ .../media_item_duration_test.dart | 117 ++++++++++++++++++ 3 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 test/services/audio_services/media_item_duration_test.dart diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index c511da61..bd5271ff 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -47,17 +47,31 @@ class AudioServices with WidgetsBindingObserver { Future addTrack(SpotubeTrackObject track) async { await smtc?.addTrack(track); - mobile?.addItem(MediaItem( + mobile?.addItem(mediaItemFromTrack(track)); + } + + /// Maps a track to the [MediaItem] published to the OS media session. + /// + /// Album/playlist tracks can carry an unknown (zero) duration depending on + /// the metadata source. Report it as `null` (unknown) instead of + /// [Duration.zero] so the OS doesn't render a bogus `0:00 / 0:00` progress + /// bar. The real duration is propagated by [MobileAudioService] once the + /// player discovers it. + @visibleForTesting + static MediaItem mediaItemFromTrack(SpotubeTrackObject track) { + return MediaItem( id: track.id, album: track.album.name, title: track.name, artist: track.artists.asString(), - duration: Duration(milliseconds: track.durationMs), + duration: track.durationMs > 0 + ? Duration(milliseconds: track.durationMs) + : null, artUri: (track.album.images).asUri( placeholder: ImagePlaceholder.albumArt, ), playable: true, - )); + ); } void activateSession() { diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 16a3618e..e0086cd2 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; +import 'package:flutter/foundation.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -72,6 +73,29 @@ class MobileAudioService extends BaseAudioHandler { audioPlayer.bufferedPositionStream.listen((pos) async { playbackState.add(await _transformEvent()); }); + + // Tracks coming from album/playlist metadata may carry an unknown + // (zero) duration, so the [MediaItem] sent to the OS initially has no + // duration. Once the player has loaded the source and discovered the + // real duration, propagate it to the active [MediaItem] so the + // MediaSession/Now Playing UI shows duration, progress and seeking. + audioPlayer.durationStream.listen((duration) { + final updatedItem = + mediaItemWithDuration(mediaItem.valueOrNull, duration); + if (updatedItem != null) { + mediaItem.add(updatedItem); + } + }); + } + + /// Returns [item] updated with the player-discovered [duration], or `null` + /// when no update should be published (no active item, unknown duration or + /// the item already has that exact duration). + @visibleForTesting + static MediaItem? mediaItemWithDuration(MediaItem? item, Duration duration) { + if (item == null || duration <= Duration.zero) return null; + if (item.duration == duration) return null; + return item.copyWith(duration: duration); } void addItem(MediaItem item) { diff --git a/test/services/audio_services/media_item_duration_test.dart b/test/services/audio_services/media_item_duration_test.dart new file mode 100644 index 00000000..67d1de88 --- /dev/null +++ b/test/services/audio_services/media_item_duration_test.dart @@ -0,0 +1,117 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/models/metadata/metadata.dart'; +import 'package:spotube/services/audio_services/audio_services.dart'; +import 'package:spotube/services/audio_services/mobile_audio_service.dart'; + +void main() { + group('AudioServices.mediaItemFromTrack', () { + test('maps a track with a known duration', () { + final item = AudioServices.mediaItemFromTrack(FakeData.track); + + expect(item.id, FakeData.track.id); + expect(item.title, FakeData.track.name); + expect(item.album, FakeData.track.album.name); + expect(item.playable, true); + expect(item.duration, const Duration(minutes: 3)); + }); + + test( + 'reports an unknown (zero) duration as null instead of Duration.zero', + () { + // Album/playlist tracks can come with durationMs == 0 depending on + // the metadata source (issue #3077). The OS must be told the duration + // is unknown, not that it is 0:00. + final track = SpotubeTrackObject.full( + id: "1", + name: "A good track", + externalUri: "https://example.com", + album: FakeData.albumSimple, + durationMs: 0, + isrc: "USUM72112345", + explicit: false, + ); + + final item = AudioServices.mediaItemFromTrack(track); + + expect(item.duration, isNull); + }, + ); + }); + + group('MobileAudioService.mediaItemWithDuration', () { + const item = MediaItem( + id: "1", + title: "A good track", + album: "A good album", + artist: "What an artist", + playable: true, + ); + + test('returns null when there is no active media item', () { + expect( + MobileAudioService.mediaItemWithDuration( + null, + const Duration(minutes: 3), + ), + isNull, + ); + }); + + test('returns null when the discovered duration is unknown', () { + expect( + MobileAudioService.mediaItemWithDuration(item, Duration.zero), + isNull, + ); + expect( + MobileAudioService.mediaItemWithDuration( + item, + const Duration(seconds: -1), + ), + isNull, + ); + }); + + test('returns null when the item already has that duration', () { + final withDuration = item.copyWith(duration: const Duration(minutes: 3)); + + expect( + MobileAudioService.mediaItemWithDuration( + withDuration, + const Duration(minutes: 3), + ), + isNull, + ); + }); + + test('fills in an initially unknown duration once discovered', () { + final updated = MobileAudioService.mediaItemWithDuration( + item, + const Duration(minutes: 3), + ); + + expect(updated, isNotNull); + expect(updated!.duration, const Duration(minutes: 3)); + // Everything else must be preserved. + expect(updated.id, item.id); + expect(updated.title, item.title); + expect(updated.album, item.album); + expect(updated.artist, item.artist); + expect(updated.playable, item.playable); + }); + + test('replaces a stale metadata duration with the player duration', () { + final withMetadataDuration = + item.copyWith(duration: const Duration(minutes: 3)); + + final updated = MobileAudioService.mediaItemWithDuration( + withMetadataDuration, + const Duration(minutes: 3, seconds: 7), + ); + + expect(updated, isNotNull); + expect(updated!.duration, const Duration(minutes: 3, seconds: 7)); + }); + }); +}