mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
fix(player): restore lyrics after cold start
This commit is contained in:
@@ -8,7 +8,6 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
@@ -173,6 +172,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
ProviderSubscription<AsyncValue<MediaItem?>>? _mediaItemSub;
|
||||
String? _loadedSource;
|
||||
String? _loadedResolvedSource;
|
||||
String? _loadedMetadataPath;
|
||||
Map<String, dynamic>? _metadata;
|
||||
ParsedLyrics _lyrics = ParsedLyrics.empty;
|
||||
bool _loadingMeta = false;
|
||||
@@ -201,7 +201,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadMetadataForItem(MediaItem? item) {
|
||||
void _loadMetadataForItem(
|
||||
MediaItem? item, {
|
||||
bool inspectUnresolvedContentUri = false,
|
||||
}) {
|
||||
if (item == null) return;
|
||||
final source = item.extras?['source']?.toString() ?? '';
|
||||
if (source.isEmpty) return;
|
||||
@@ -211,6 +214,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
source,
|
||||
resolvedSource: resolvedSource,
|
||||
fallbackMetadata: playbackAudioMetadataFromMediaItem(item),
|
||||
inspectUnresolvedContentUri: inspectUnresolvedContentUri,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -219,41 +223,51 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
String source, {
|
||||
String? resolvedSource,
|
||||
Map<String, dynamic> fallbackMetadata = const {},
|
||||
bool inspectUnresolvedContentUri = false,
|
||||
}) async {
|
||||
final effectiveResolvedSource = resolvedSource?.trim();
|
||||
if (source == _loadedSource &&
|
||||
effectiveResolvedSource == _loadedResolvedSource) {
|
||||
final path =
|
||||
(effectiveResolvedSource != null && effectiveResolvedSource.isNotEmpty)
|
||||
? effectiveResolvedSource
|
||||
: source;
|
||||
final unresolvedContentUri =
|
||||
path == source && source.startsWith('content://');
|
||||
final sameItem =
|
||||
source == _loadedSource &&
|
||||
effectiveResolvedSource == _loadedResolvedSource;
|
||||
|
||||
if (sameItem) {
|
||||
if (_metadata == null && fallbackMetadata.isNotEmpty) {
|
||||
setState(() => _metadata = fallbackMetadata);
|
||||
}
|
||||
return;
|
||||
if (_loadingMeta || _loadedMetadataPath == path) return;
|
||||
if (unresolvedContentUri && !inspectUnresolvedContentUri) return;
|
||||
setState(() => _loadingMeta = true);
|
||||
} else {
|
||||
_loadedSource = source;
|
||||
_loadedResolvedSource = effectiveResolvedSource;
|
||||
_loadedMetadataPath = null;
|
||||
setState(() {
|
||||
_loadingMeta = !unresolvedContentUri || inspectUnresolvedContentUri;
|
||||
_metadata = fallbackMetadata.isEmpty ? null : fallbackMetadata;
|
||||
_lyrics = ParsedLyrics.empty;
|
||||
});
|
||||
}
|
||||
_loadedSource = source;
|
||||
_loadedResolvedSource = effectiveResolvedSource;
|
||||
setState(() {
|
||||
_loadingMeta = true;
|
||||
_metadata = fallbackMetadata.isEmpty ? null : fallbackMetadata;
|
||||
_lyrics = ParsedLyrics.empty;
|
||||
});
|
||||
|
||||
// Avoid copying a restored SAF file merely because the player shell became
|
||||
// visible. If the user opens Lyrics before playback resolves a local temp
|
||||
// source, inspect the content URI on demand instead.
|
||||
if (unresolvedContentUri && !inspectUnresolvedContentUri) return;
|
||||
|
||||
try {
|
||||
final path =
|
||||
(effectiveResolvedSource != null &&
|
||||
effectiveResolvedSource.isNotEmpty)
|
||||
? effectiveResolvedSource
|
||||
: source;
|
||||
if (path == source && source.startsWith('content://')) {
|
||||
if (mounted && _loadedSource == source) {
|
||||
setState(() => _loadingMeta = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final meta = await PlatformBridge.readFileMetadata(path);
|
||||
final meta = await readPlaybackFileMetadataWithRetry(path);
|
||||
if (!mounted ||
|
||||
_loadedSource != source ||
|
||||
_loadedResolvedSource != effectiveResolvedSource) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loadedMetadataPath = path;
|
||||
_metadata = mergePlaybackFileMetadata(fallbackMetadata, meta);
|
||||
_lyrics = LyricsParser.parse((meta['lyrics'] ?? '').toString());
|
||||
_loadingMeta = false;
|
||||
@@ -378,6 +392,12 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
if (_currentPage != page) {
|
||||
setState(() => _currentPage = page);
|
||||
}
|
||||
if (page == 1) {
|
||||
_loadMetadataForItem(
|
||||
ref.read(currentMediaItemProvider).value,
|
||||
inspectUnresolvedContentUri: true,
|
||||
);
|
||||
}
|
||||
},
|
||||
children: [
|
||||
_playerPage(mediaItem, controller, colorScheme),
|
||||
|
||||
@@ -176,6 +176,50 @@ Map<String, dynamic> mergePlaybackFileMetadata(
|
||||
return merged;
|
||||
}
|
||||
|
||||
typedef PlaybackMetadataReader =
|
||||
Future<Map<String, dynamic>> Function(String path);
|
||||
|
||||
/// Reads playback metadata with a small bounded retry window for transient
|
||||
/// cold-start/native bridge failures.
|
||||
///
|
||||
/// The native bridge reports some read failures as an `error` field instead
|
||||
/// of throwing. Treat both forms identically so Now Playing does not cache an
|
||||
/// empty Lyrics view until the route is reopened. A successful response with
|
||||
/// no lyrics is still final and is never retried.
|
||||
Future<Map<String, dynamic>> readPlaybackFileMetadataWithRetry(
|
||||
String path, {
|
||||
PlaybackMetadataReader? reader,
|
||||
List<Duration> retryDelays = const [
|
||||
Duration.zero,
|
||||
Duration(milliseconds: 250),
|
||||
Duration(milliseconds: 750),
|
||||
],
|
||||
}) async {
|
||||
final read = reader ?? PlatformBridge.readFileMetadata;
|
||||
final delays = retryDelays.isEmpty ? const [Duration.zero] : retryDelays;
|
||||
Object? lastError;
|
||||
var lastStack = StackTrace.current;
|
||||
|
||||
for (final delay in delays) {
|
||||
if (delay > Duration.zero) await Future<void>.delayed(delay);
|
||||
try {
|
||||
final metadata = await read(path);
|
||||
final reportedError = metadata['error']?.toString().trim() ?? '';
|
||||
if (reportedError.isEmpty) return metadata;
|
||||
lastError = StateError(reportedError);
|
||||
lastStack = StackTrace.current;
|
||||
} catch (error, stack) {
|
||||
lastError = error;
|
||||
lastStack = stack;
|
||||
}
|
||||
}
|
||||
|
||||
Error.throwWithStackTrace(
|
||||
lastError ?? StateError('Metadata reader returned no result'),
|
||||
lastStack,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a safe source-start position for restored playback. A completed
|
||||
/// snapshot starts over instead of immediately completing again on resume.
|
||||
Duration normalizedPlaybackResumePosition(
|
||||
|
||||
@@ -67,4 +67,38 @@ void main() {
|
||||
Duration.zero,
|
||||
);
|
||||
});
|
||||
|
||||
test('cold-start metadata read retries thrown and reported errors', () async {
|
||||
var calls = 0;
|
||||
|
||||
final metadata = await readPlaybackFileMetadataWithRetry(
|
||||
'/music/track.flac',
|
||||
retryDelays: const [Duration.zero, Duration.zero, Duration.zero],
|
||||
reader: (path) async {
|
||||
calls++;
|
||||
if (calls == 1) throw StateError('backend not ready');
|
||||
if (calls == 2) return {'error': 'file temporarily unavailable'};
|
||||
return {'lyrics': '[00:01.00]Ready'};
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls, 3);
|
||||
expect(metadata['lyrics'], '[00:01.00]Ready');
|
||||
});
|
||||
|
||||
test('successful metadata without lyrics is not retried', () async {
|
||||
var calls = 0;
|
||||
|
||||
final metadata = await readPlaybackFileMetadataWithRetry(
|
||||
'/music/instrumental.flac',
|
||||
retryDelays: const [Duration.zero, Duration.zero, Duration.zero],
|
||||
reader: (path) async {
|
||||
calls++;
|
||||
return {'title': 'Instrumental'};
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls, 1);
|
||||
expect(metadata['title'], 'Instrumental');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user