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 <noreply@anthropic.com>
This commit is contained in:
Rahul Agarwal 2026-07-18 18:26:18 +05:30
parent 69a310c78f
commit 9c92e2529e
3 changed files with 158 additions and 3 deletions

View File

@ -47,17 +47,31 @@ class AudioServices with WidgetsBindingObserver {
Future<void> 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() {

View File

@ -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) {

View File

@ -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));
});
});
}