mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
fix(player): preserve offline motion artwork and smooth cover changes
This commit is contained in:
1 parent
dc25370350
commit
0c930c691f
25 files changed
+1464
-339
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user