fix(player): preserve offline motion artwork and smooth cover changes

This commit is contained in:
zarzet committed 2026-09-20 12:20:24 +07:00
1 parent dc25370350
commit 0c930c691f
25 files changed
+1464 -339

No files matched your search

+1 -1
View File
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/providers/download_queue_state.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/services/download_motion_artwork_source.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/replaygain_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
@@ -38,7 +39,6 @@ import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/download_error_type.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/progress_stream_poller.dart';
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
@@ -106,68 +106,15 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
) ??
item.track.artistName,
);
final extensions = ref.read(extensionProvider).extensions;
final preferred = ref.read(settingsProvider).searchProvider;
final providers =
<String>{
if (item.track.source?.isNotEmpty == true) item.track.source!,
?preferred,
}
.where(
(id) => extensions.any(
(extension) =>
extension.id == id &&
extension.enabled &&
extension.hasCustomSearch,
),
)
.toList();
final store = ref.read(motionArtworkStoreProvider);
final artwork = await store.save(
album,
resolveSource: () async {
final direct = normalizeRemoteHttpUrl(
item.track.headerVideoUrl ??
track.headerVideoUrl ??
result['header_video']?.toString(),
);
if (direct != null) return direct;
final source = item.track.source;
final albumId = item.track.albumId;
if (source != null &&
providers.contains(source) &&
albumId?.isNotEmpty == true) {
try {
final metadata = await PlatformBridge.getProviderMetadata(
source,
'album',
stripPrefixedResourceId(albumId!),
).timeout(const Duration(seconds: 6));
final info = metadata['album_info'];
final url = normalizeRemoteHttpUrl(
((info is Map ? info['header_video'] : null) ??
metadata['header_video'])
?.toString(),
);
if (url != null) return url;
} catch (_) {}
}
return findPlayerMotionArtwork(
album: album,
providerIds: providers,
search: (provider, query) =>
PlatformBridge.customSearchWithExtension(
provider,
query,
options: {'filter': 'album', 'limit': 8},
).timeout(const Duration(seconds: 6)),
loadAlbum: (provider, id) => PlatformBridge.getProviderMetadata(
provider,
'album',
id,
).timeout(const Duration(seconds: 6)),
);
},
resolveSource: () => resolveDownloadMotionArtworkSource(
original: item.track,
downloaded: track,
result: result,
getMetadata: PlatformBridge.getProviderMetadata,
),
);
if (artwork != null && ref.mounted) {
ref.invalidate(playerMotionArtworkProvider(album));
@@ -0,0 +1,26 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:video_player/video_player.dart';
/// The mini player prepares the current offline cover, paused and silent.
/// The full player borrows it so opening the route does not restart decoding.
final playerArtworkVideoProvider = FutureProvider.autoDispose
.family<VideoPlayerController, String>((ref, source) async {
final uri = Uri.parse(source);
if (uri.scheme != 'file') {
throw ArgumentError('Player artwork must be saved locally');
}
final controller = VideoPlayerController.file(
File.fromUri(uri),
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
ref.onDispose(() => unawaited(controller.dispose()));
await controller.initialize();
if (!ref.mounted) return controller;
await controller.setVolume(0);
if (!ref.mounted) return controller;
await controller.setLooping(true);
return controller;
});
@@ -1,118 +1,16 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/motion_artwork_store.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/ttl_cache.dart';
typedef PlayerArtworkAlbum = ({String album, String artist});
final motionArtworkStoreProvider = Provider((ref) => MotionArtworkStore());
final _motionCache = TtlCache<Future<String?>>(
const Duration(minutes: 10),
maxEntries: 32,
);
/// Resolve the same album motion artwork used by collection headers. Cache by
/// album so advancing through its tracks does not repeat metadata requests.
/// Playback only reads artwork already saved with a download. Opening a player
/// must never search other extensions or trigger an artwork download.
final playerMotionArtworkProvider = FutureProvider.autoDispose
.family<MotionArtwork?, PlayerArtworkAlbum>((ref, album) async {
if (album.album.trim().isEmpty || album.artist.trim().isEmpty) {
return null;
}
final local = await ref.read(motionArtworkStoreProvider).find(album);
if (local != null) return local;
if (!ref.mounted) return null;
final extensions = ref.watch(extensionProvider);
final preferred = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final enabled = extensions.extensions
.where((extension) => extension.enabled && extension.hasCustomSearch)
.map((extension) => extension.id)
.toSet();
final providers = <String>{
if (preferred != null && enabled.contains(preferred)) preferred,
...extensions.metadataProviderPriority.where(enabled.contains),
...enabled,
}.toList();
if (providers.isEmpty) return null;
final key = jsonEncode([album.album, album.artist, providers]);
final cached = _motionCache.get(key);
if (cached != null) {
final source = await cached;
return source == null ? null : MotionArtwork(source);
}
final request = findPlayerMotionArtwork(
album: album,
providerIds: providers,
search: (provider, query) => PlatformBridge.customSearchWithExtension(
provider,
query,
options: {'filter': 'album', 'limit': 8},
).timeout(const Duration(seconds: 6)),
loadAlbum: (provider, id) => PlatformBridge.getProviderMetadata(
provider,
'album',
id,
).timeout(const Duration(seconds: 6)),
);
_motionCache.set(key, request);
final source = await request;
return source == null ? null : MotionArtwork(source);
return ref.watch(motionArtworkStoreProvider).find(album);
});
Future<String?> findPlayerMotionArtwork({
required PlayerArtworkAlbum album,
required List<String> providerIds,
required Future<List<Map<String, dynamic>>> Function(
String provider,
String query,
)
search,
required Future<Map<String, dynamic>> Function(String provider, String id)
loadAlbum,
}) async {
String normalized(String value) =>
value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
for (final provider in providerIds) {
try {
final results = await search(provider, '${album.album} ${album.artist}');
for (final result in results) {
final name = (result['name'] ?? result['album_name'] ?? '').toString();
final artist =
(result['artists'] ??
result['artist'] ??
result['artist_name'] ??
'')
.toString();
// Never attach a similarly named album's video to the playing track.
if (normalized(name) != normalized(album.album) ||
normalized(artist) != normalized(album.artist)) {
continue;
}
final direct = normalizeRemoteHttpUrl(
result['header_video']?.toString(),
);
if (direct != null) return direct;
final id = (result['id'] ?? result['album_id'] ?? '').toString();
if (id.isEmpty) continue;
final metadata = await loadAlbum(provider, id);
final info = metadata['album_info'];
final video = normalizeRemoteHttpUrl(
((info is Map ? info['header_video'] : null) ??
metadata['header_video'])
?.toString(),
);
if (video != null) return video;
}
} catch (_) {
// Offline, unsupported providers and expired links keep the static cover.
}
}
return null;
}