mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
feat(mornye): cache motion covers and refine player and search
Save album motion videos in app storage during downloads and prefer offline playback with an aspect-aware layout. Add cache management for saved videos. Center active lyrics on opening, expand lyrics during manual scrolling, and adjust landscape header spacing. Group search rows and skeletons with dividers and remove redundant result icons. Validation: Flutter analysis clean; 723 tests passed, 4 skipped; iOS simulator build installed.
This commit is contained in:
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/app_navigation_service.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_verification_retry_guard.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_state.dart';
|
||||
import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
@@ -37,6 +38,7 @@ 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';
|
||||
|
||||
|
||||
@@ -83,6 +83,100 @@ Future<String> _normalizeDecryptedIsoBmffAudioPath(
|
||||
}
|
||||
|
||||
extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
Future<void> _saveDownloadedMotionArtwork(
|
||||
Ref ref,
|
||||
DownloadItem item,
|
||||
Track track,
|
||||
Map<String, dynamic> result,
|
||||
) async {
|
||||
// Store independently of history preferences and the selected UI theme.
|
||||
// A later switch to Mornye must still work without network access.
|
||||
try {
|
||||
final album = (
|
||||
album:
|
||||
_resolveMetadataText(
|
||||
track.albumName,
|
||||
result['album']?.toString(),
|
||||
) ??
|
||||
item.track.albumName,
|
||||
artist:
|
||||
_resolveMetadataText(
|
||||
track.artistName,
|
||||
result['artist']?.toString(),
|
||||
) ??
|
||||
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)),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (artwork != null && ref.mounted) {
|
||||
ref.invalidate(playerMotionArtworkProvider(album));
|
||||
}
|
||||
} catch (error) {
|
||||
_log.w('Optional motion artwork was not saved: ${error.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<_AutoConversionOutcome> _autoConvertDownloadedFile({
|
||||
required String itemId,
|
||||
required String filePath,
|
||||
|
||||
@@ -1245,6 +1245,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
|
||||
if (result['native_finalized'] == true) {
|
||||
final nativeFinalizedFilePath = filePath;
|
||||
await _saveDownloadedMotionArtwork(ref, item, item.track, result);
|
||||
await persistBeforePublishingDownloadCompletion(
|
||||
persist: () async {
|
||||
if (!settings.saveDownloadHistory) return;
|
||||
@@ -1532,6 +1533,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
lowerFilePath.endsWith('.ogg');
|
||||
|
||||
final completedFilePath = filePath;
|
||||
await _saveDownloadedMotionArtwork(ref, item, trackToDownload, result);
|
||||
final lyricsAvailability = await _resolveFinalLyricsAvailability(
|
||||
filePath: completedFilePath,
|
||||
externalLrcWritten: externalLrcWritten,
|
||||
|
||||
@@ -1717,6 +1717,12 @@ class _DownloadRun {
|
||||
externalLrcWritten: externalLrcWritten,
|
||||
);
|
||||
|
||||
await n._saveDownloadedMotionArtwork(
|
||||
n.ref,
|
||||
item,
|
||||
trackToDownload,
|
||||
result,
|
||||
);
|
||||
await persistBeforePublishingDownloadCompletion(
|
||||
persist: () async {
|
||||
if (!settings.saveDownloadHistory) return;
|
||||
|
||||
@@ -4,11 +4,14 @@ 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,
|
||||
@@ -17,10 +20,13 @@ final _motionCache = TtlCache<Future<String?>>(
|
||||
/// Resolve the same album motion artwork used by collection headers. Cache by
|
||||
/// album so advancing through its tracks does not repeat metadata requests.
|
||||
final playerMotionArtworkProvider = FutureProvider.autoDispose
|
||||
.family<String?, PlayerArtworkAlbum>((ref, album) {
|
||||
.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),
|
||||
@@ -37,7 +43,10 @@ final playerMotionArtworkProvider = FutureProvider.autoDispose
|
||||
if (providers.isEmpty) return null;
|
||||
final key = jsonEncode([album.album, album.artist, providers]);
|
||||
final cached = _motionCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
if (cached != null) {
|
||||
final source = await cached;
|
||||
return source == null ? null : MotionArtwork(source);
|
||||
}
|
||||
final request = findPlayerMotionArtwork(
|
||||
album: album,
|
||||
providerIds: providers,
|
||||
@@ -53,7 +62,8 @@ final playerMotionArtworkProvider = FutureProvider.autoDispose
|
||||
).timeout(const Duration(seconds: 6)),
|
||||
);
|
||||
_motionCache.set(key, request);
|
||||
return request;
|
||||
final source = await request;
|
||||
return source == null ? null : MotionArtwork(source);
|
||||
});
|
||||
|
||||
Future<String?> findPlayerMotionArtwork({
|
||||
|
||||
@@ -229,13 +229,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final collectionName = result['name'] as String?;
|
||||
final headerVideo = type == 'album'
|
||||
? normalizeRemoteHttpUrl(result['header_video']?.toString())
|
||||
: null;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => Track.fromBackendMap(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
playlistName: type == 'playlist' ? collectionName : null,
|
||||
),
|
||||
).copyWith(headerVideoUrl: headerVideo),
|
||||
)
|
||||
.toList();
|
||||
state = TrackState(
|
||||
|
||||
Reference in New Issue
Block a user