mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +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:
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"cacheMotionArtwork": "Offline motion covers",
|
||||
"cacheMotionArtworkDesc": "Album videos saved with downloads. Removing them keeps your music files and static covers.",
|
||||
"nowPlayingVolume": "Volume",
|
||||
"@nowPlayingVolume": {
|
||||
"description": "System volume control label in the Mornye player"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"cacheMotionArtwork": "Cover bergerak offline",
|
||||
"cacheMotionArtworkDesc": "Video album yang disimpan saat download. Menghapusnya tetap menyimpan file musik dan cover biasa.",
|
||||
"nowPlayingVolume": "Volume",
|
||||
"@nowPlayingVolume": {
|
||||
"description": "Label kontrol volume sistem di pemutar Mornye"
|
||||
|
||||
@@ -14,6 +14,7 @@ class Track {
|
||||
final String? artistId;
|
||||
final String? albumId;
|
||||
final String? coverUrl;
|
||||
final String? headerVideoUrl;
|
||||
final String? isrc;
|
||||
final String? previewUrl;
|
||||
final int duration;
|
||||
@@ -46,6 +47,7 @@ class Track {
|
||||
this.artistId,
|
||||
this.albumId,
|
||||
this.coverUrl,
|
||||
this.headerVideoUrl,
|
||||
this.isrc,
|
||||
this.previewUrl,
|
||||
required this.duration,
|
||||
@@ -128,6 +130,7 @@ class Track {
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
headerVideoUrl: normalizeRemoteHttpUrl(data['header_video']?.toString()),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -162,6 +165,7 @@ class Track {
|
||||
String? artistId,
|
||||
String? albumId,
|
||||
String? coverUrl,
|
||||
String? headerVideoUrl,
|
||||
String? isrc,
|
||||
String? previewUrl,
|
||||
int? duration,
|
||||
@@ -194,6 +198,7 @@ class Track {
|
||||
artistId: artistId ?? this.artistId,
|
||||
albumId: albumId ?? this.albumId,
|
||||
coverUrl: coverUrl ?? this.coverUrl,
|
||||
headerVideoUrl: headerVideoUrl ?? this.headerVideoUrl,
|
||||
isrc: isrc ?? this.isrc,
|
||||
previewUrl: previewUrl ?? this.previewUrl,
|
||||
duration: duration ?? this.duration,
|
||||
|
||||
@@ -15,6 +15,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
artistId: json['artistId'] as String?,
|
||||
albumId: json['albumId'] as String?,
|
||||
coverUrl: json['coverUrl'] as String?,
|
||||
headerVideoUrl: json['headerVideoUrl'] as String?,
|
||||
isrc: json['isrc'] as String?,
|
||||
previewUrl: json['previewUrl'] as String?,
|
||||
duration: (json['duration'] as num).toInt(),
|
||||
@@ -52,6 +53,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'artistId': instance.artistId,
|
||||
'albumId': instance.albumId,
|
||||
'coverUrl': instance.coverUrl,
|
||||
'headerVideoUrl': instance.headerVideoUrl,
|
||||
'isrc': instance.isrc,
|
||||
'previewUrl': instance.previewUrl,
|
||||
'duration': instance.duration,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -212,7 +212,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
t as Map<String, dynamic>,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
).copyWith(headerVideoUrl: normalizeRemoteHttpUrl(headerVideo)),
|
||||
)
|
||||
.toList();
|
||||
|
||||
|
||||
@@ -497,14 +497,17 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
|
||||
height: 32,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showSortOptions(colorScheme),
|
||||
icon: Icon(
|
||||
mornye ? mornyeIconFor(Icons.swap_vert) : Icons.swap_vert,
|
||||
size: 18,
|
||||
color:
|
||||
_searchSortOption != HomeSearchSortOption.defaultOrder
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
icon: mornye
|
||||
? null
|
||||
: Icon(
|
||||
Icons.swap_vert,
|
||||
size: 18,
|
||||
color:
|
||||
_searchSortOption !=
|
||||
HomeSearchSortOption.defaultOrder
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
label: Text(
|
||||
_searchSortOption != HomeSearchSortOption.defaultOrder
|
||||
? _sortOptionLabel(_searchSortOption)
|
||||
@@ -535,10 +538,12 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
|
||||
final isLast = index == itemCount - 1;
|
||||
if (mornye) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, isLast ? 0 : 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: MornyeGlassPanel.overlay(
|
||||
radius: 24,
|
||||
child: itemBuilder(index, false),
|
||||
firstInGroup: isFirst,
|
||||
lastInGroup: isLast,
|
||||
child: itemBuilder(index, !isLast),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -532,13 +532,12 @@ class _CollectionItemWidget extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
context.isMornye
|
||||
? mornyeIconFor(Icons.chevron_right)
|
||||
: Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: context.isMornye ? 18 : 24,
|
||||
),
|
||||
if (!context.isMornye)
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+397
-217
@@ -218,6 +218,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
bool _bottomDragForwarding = false;
|
||||
double _bottomDragTotal = 0;
|
||||
bool _queueSheetShowing = false;
|
||||
String? _measuredMotionSource;
|
||||
double? _motionAspectRatio;
|
||||
String? _failedMotionSource;
|
||||
bool _lyricsControlsHidden = false;
|
||||
double _lyricsScrollDistance = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -407,7 +412,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
}
|
||||
|
||||
final source = mediaItem.extras?['source']?.toString() ?? '';
|
||||
final motionArtwork = mornye && !MediaQuery.disableAnimationsOf(context)
|
||||
final resolvedMotion = mornye && !MediaQuery.disableAnimationsOf(context)
|
||||
? ref
|
||||
.watch(
|
||||
playerMotionArtworkProvider((
|
||||
@@ -417,6 +422,36 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
)
|
||||
.value
|
||||
: null;
|
||||
final motionArtwork = resolvedMotion?.source == _failedMotionSource
|
||||
? null
|
||||
: resolvedMotion;
|
||||
final motionRatio =
|
||||
motionArtwork?.aspectRatio ??
|
||||
(_measuredMotionSource == motionArtwork?.source
|
||||
? _motionAspectRatio
|
||||
: null);
|
||||
final squareArtwork =
|
||||
motionArtwork == null || (motionRatio != null && motionRatio >= 0.95);
|
||||
Widget artwork() => MornyePlayerArtwork(
|
||||
mediaItem: mediaItem,
|
||||
videoUrl: motionArtwork?.source,
|
||||
onAspectRatioChanged: (ratio) {
|
||||
if (!mounted ||
|
||||
(_measuredMotionSource == motionArtwork?.source &&
|
||||
_motionAspectRatio == ratio)) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_measuredMotionSource = motionArtwork?.source;
|
||||
_motionAspectRatio = ratio;
|
||||
});
|
||||
},
|
||||
onError: () {
|
||||
if (mounted) {
|
||||
setState(() => _failedMotionSource = motionArtwork?.source);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The Mornye route moves the entire player together. A second,
|
||||
// delayed content fade would make dismissal appear to pause partway down.
|
||||
@@ -437,21 +472,27 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
systemOverlayStyle: mornye ? SystemUiOverlayStyle.light : null,
|
||||
toolbarHeight: mornye ? 36 : kToolbarHeight,
|
||||
toolbarHeight: mornye
|
||||
? (MediaQuery.orientationOf(context) == Orientation.landscape
|
||||
? 0
|
||||
: 36)
|
||||
: kToolbarHeight,
|
||||
automaticallyImplyLeading: false,
|
||||
title: mornye
|
||||
? IconButton(
|
||||
tooltip: context.l10n.nowPlayingMinimize,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
icon: Container(
|
||||
width: 36,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
)
|
||||
? MediaQuery.orientationOf(context) == Orientation.landscape
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: context.l10n.nowPlayingMinimize,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
icon: Container(
|
||||
width: 36,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(context.l10n.nowPlayingTitle),
|
||||
centerTitle: true,
|
||||
leading: mornye
|
||||
@@ -495,7 +536,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
mediaItem,
|
||||
controller,
|
||||
colorScheme,
|
||||
squareArtwork: motionArtwork == null,
|
||||
squareArtwork: squareArtwork,
|
||||
motionArtwork: artwork(),
|
||||
artworkAspectRatio: motionRatio,
|
||||
)
|
||||
: PageView(
|
||||
controller: _pageController,
|
||||
@@ -592,15 +635,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
RepaintBoundary(
|
||||
child: MornyePlayerBackground(
|
||||
artUri: mediaItem.artUri,
|
||||
squareArtwork: motionArtwork == null,
|
||||
squareArtwork: squareArtwork,
|
||||
artworkAspectRatio: motionRatio,
|
||||
artwork: !_landscapeLyrics && _currentPage == 0
|
||||
? Hero(
|
||||
tag: kNowPlayingArtworkHeroTag,
|
||||
child: MornyePlayerArtwork(
|
||||
mediaItem: mediaItem,
|
||||
videoUrl: motionArtwork,
|
||||
),
|
||||
)
|
||||
? Hero(tag: kNowPlayingArtworkHeroTag, child: artwork())
|
||||
: null,
|
||||
),
|
||||
),
|
||||
@@ -769,7 +807,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
}
|
||||
|
||||
void _toggleMornyeLyrics() {
|
||||
setState(() => _currentPage = _currentPage == 1 ? 0 : 1);
|
||||
setState(() {
|
||||
_currentPage = _currentPage == 1 ? 0 : 1;
|
||||
_lyricsControlsHidden = false;
|
||||
_lyricsScrollDistance = 0;
|
||||
});
|
||||
if (_currentPage == 1) {
|
||||
_loadMetadataForItem(
|
||||
ref.read(currentMediaItemProvider).value,
|
||||
@@ -783,6 +825,8 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
MusicPlayerController controller,
|
||||
ColorScheme colorScheme, {
|
||||
required bool squareArtwork,
|
||||
required Widget motionArtwork,
|
||||
double? artworkAspectRatio,
|
||||
}) {
|
||||
final showLyrics = _landscapeLyrics || _currentPage == 1;
|
||||
final showQueue = !_landscapeLyrics && _currentPage == 2;
|
||||
@@ -790,217 +834,272 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
final motion = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 380);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final landscape = screenSize.width > screenSize.height;
|
||||
final scale = MediaQuery.textScalerOf(context).scale(17) / 17;
|
||||
Widget stage({bool artworkOnly = false}) => LayoutBuilder(
|
||||
builder: (context, stage) {
|
||||
final compact = compactStage && !artworkOnly;
|
||||
final artSize = (stage.maxWidth - 56).clamp(
|
||||
0.0,
|
||||
(stage.maxHeight - 24).clamp(0.0, 360.0),
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
top: 76,
|
||||
child: IgnorePointer(
|
||||
ignoring: !compact,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !compact,
|
||||
return NotificationListener<ScrollUpdateNotification>(
|
||||
onNotification: (notification) {
|
||||
if (!showLyrics ||
|
||||
_landscapeLyrics ||
|
||||
notification.dragDetails == null ||
|
||||
notification.metrics.axis != Axis.vertical) {
|
||||
return false;
|
||||
}
|
||||
final delta = notification.scrollDelta ?? 0;
|
||||
if (delta.sign != _lyricsScrollDistance.sign) _lyricsScrollDistance = 0;
|
||||
_lyricsScrollDistance += delta;
|
||||
if (_lyricsScrollDistance.abs() >= 16) {
|
||||
final hidden = _lyricsScrollDistance > 0;
|
||||
if (hidden != _lyricsControlsHidden) {
|
||||
setState(() => _lyricsControlsHidden = hidden);
|
||||
}
|
||||
_lyricsScrollDistance = 0;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final landscape = screenSize.width > screenSize.height;
|
||||
final scale = MediaQuery.textScalerOf(context).scale(17) / 17;
|
||||
Widget stage({bool artworkOnly = false}) => LayoutBuilder(
|
||||
builder: (context, stage) {
|
||||
final compact = compactStage && !artworkOnly;
|
||||
final artSize = (stage.maxWidth - 56).clamp(
|
||||
0.0,
|
||||
(stage.maxHeight - 24).clamp(0.0, 360.0),
|
||||
);
|
||||
final ratio = artworkAspectRatio ?? 1.0;
|
||||
final artWidth = ratio < 1 ? artSize * ratio : artSize;
|
||||
final artHeight = ratio > 1 ? artSize / ratio : artSize;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
top: 76,
|
||||
child: IgnorePointer(
|
||||
ignoring: !compact,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !compact,
|
||||
child: AnimatedSwitcher(
|
||||
duration: motion,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
transitionBuilder: (child, animation) =>
|
||||
FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 0.035),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
layoutBuilder: (currentChild, previousChildren) =>
|
||||
Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
for (final child in previousChildren)
|
||||
IgnorePointer(
|
||||
child: ExcludeSemantics(child: child),
|
||||
),
|
||||
?currentChild,
|
||||
],
|
||||
),
|
||||
// Keep the outgoing panel until it fades out;
|
||||
// closing the queue must never substitute lyrics.
|
||||
child: compact
|
||||
? KeyedSubtree(
|
||||
key: ValueKey(_currentPage),
|
||||
child: showQueue
|
||||
? MornyePlayerQueue(
|
||||
colorScheme: colorScheme,
|
||||
onShuffleLibrary: () =>
|
||||
_shuffleLibrary(controller),
|
||||
)
|
||||
: _lyricsSection(
|
||||
colorScheme,
|
||||
isActive: showLyrics,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: artworkOnly ? (stage.maxHeight - artHeight) / 2 : 8,
|
||||
left: artworkOnly ? (stage.maxWidth - artWidth) / 2 : 28,
|
||||
width: artworkOnly ? artWidth : 48,
|
||||
height: artworkOnly ? artHeight : 48,
|
||||
child: HeroMode(
|
||||
enabled: artworkOnly || compactStage,
|
||||
child: AnimatedSwitcher(
|
||||
duration: motion,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
layoutBuilder: (current, previous) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
for (final child in previous)
|
||||
HeroMode(
|
||||
enabled: false,
|
||||
child: IgnorePointer(child: child),
|
||||
),
|
||||
?current,
|
||||
],
|
||||
),
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 0.035),
|
||||
end: Offset.zero,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(
|
||||
begin: 0.85,
|
||||
end: 1.0,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
layoutBuilder: (currentChild, previousChildren) =>
|
||||
Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
for (final child in previousChildren)
|
||||
IgnorePointer(
|
||||
child: ExcludeSemantics(child: child),
|
||||
),
|
||||
?currentChild,
|
||||
],
|
||||
),
|
||||
// Keep the outgoing panel until it fades out;
|
||||
// closing the queue must never substitute lyrics.
|
||||
child: compact
|
||||
? KeyedSubtree(
|
||||
key: ValueKey(_currentPage),
|
||||
child: showQueue
|
||||
? MornyePlayerQueue(
|
||||
colorScheme: colorScheme,
|
||||
onShuffleLibrary: () =>
|
||||
_shuffleLibrary(controller),
|
||||
)
|
||||
: _lyricsSection(
|
||||
colorScheme,
|
||||
isActive: showLyrics,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: artworkOnly ? (stage.maxHeight - artSize) / 2 : 8,
|
||||
left: artworkOnly ? (stage.maxWidth - artSize) / 2 : 28,
|
||||
width: artworkOnly ? artSize : 48,
|
||||
height: artworkOnly ? artSize : 48,
|
||||
child: HeroMode(
|
||||
enabled: artworkOnly || compactStage,
|
||||
child: AnimatedSwitcher(
|
||||
duration: motion,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
layoutBuilder: (current, previous) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
for (final child in previous)
|
||||
HeroMode(
|
||||
enabled: false,
|
||||
child: IgnorePointer(child: child),
|
||||
),
|
||||
?current,
|
||||
],
|
||||
),
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(
|
||||
begin: 0.85,
|
||||
end: 1.0,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: !artworkOnly && !compactStage
|
||||
? null
|
||||
: _artworkDragRegion(
|
||||
context,
|
||||
Hero(
|
||||
tag: kNowPlayingArtworkHeroTag,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
cacheWidth:
|
||||
(360 *
|
||||
MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
))
|
||||
.round(),
|
||||
child: !artworkOnly && !compactStage
|
||||
? null
|
||||
: _artworkDragRegion(
|
||||
context,
|
||||
Hero(
|
||||
tag: kNowPlayingArtworkHeroTag,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: artworkOnly
|
||||
? motionArtwork
|
||||
: PlayerArtwork(
|
||||
artUri: mediaItem.artUri
|
||||
?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
cacheWidth:
|
||||
(360 *
|
||||
MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
))
|
||||
.round(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 88,
|
||||
right: 28,
|
||||
top: 4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !compact,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !compact,
|
||||
child: AnimatedOpacity(
|
||||
opacity: compact ? 1 : 0,
|
||||
duration: motion,
|
||||
child: _trackHeader(
|
||||
mediaItem,
|
||||
colorScheme,
|
||||
compact: true,
|
||||
Positioned(
|
||||
left: 88,
|
||||
right: 28,
|
||||
top: 4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !compact,
|
||||
child: ExcludeSemantics(
|
||||
excluding: !compact,
|
||||
child: AnimatedOpacity(
|
||||
opacity: compact ? 1 : 0,
|
||||
duration: motion,
|
||||
child: _trackHeader(
|
||||
mediaItem,
|
||||
colorScheme,
|
||||
compact: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final volumeGap =
|
||||
8 +
|
||||
(squareArtwork
|
||||
? (constraints.maxHeight - 480).clamp(0.0, 48.0)
|
||||
: 0);
|
||||
// Share the spare space above and below the transport row so it sits
|
||||
// between the timeline and volume without moving either slider.
|
||||
final transportShift = landscape
|
||||
? 0.0
|
||||
: ((volumeGap - 16) / 2).clamp(0.0, 20.0);
|
||||
Widget controls() => _PlaybackControls(
|
||||
mediaId: mediaItem.id,
|
||||
duration: mediaItem.duration ?? Duration.zero,
|
||||
controller: controller,
|
||||
colorScheme: colorScheme,
|
||||
qualityLabel: _qualityLabel(),
|
||||
compact: landscape,
|
||||
transportTopPadding: 16 + transportShift,
|
||||
);
|
||||
|
||||
if (landscape) {
|
||||
return MornyeLandscapePlayer(
|
||||
artwork: stage(artworkOnly: true),
|
||||
header: _trackHeader(mediaItem, colorScheme, compact: true),
|
||||
lyrics: _lyricsSection(colorScheme, isActive: true),
|
||||
queue: MornyePlayerQueue(
|
||||
colorScheme: colorScheme,
|
||||
onShuffleLibrary: () => _shuffleLibrary(controller),
|
||||
),
|
||||
controls: controls(),
|
||||
volume: const MornyeVolumeControl(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget content() => Column(
|
||||
children: [
|
||||
Expanded(child: stage()),
|
||||
AnimatedSize(
|
||||
duration: motion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
child: compactStage
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 12, 28, 8),
|
||||
child: _trackHeader(mediaItem, colorScheme),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: motion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: ClipRect(
|
||||
child: Align(
|
||||
heightFactor: showLyrics && _lyricsControlsHidden ? 0 : 1,
|
||||
child: IgnorePointer(
|
||||
ignoring: showLyrics && _lyricsControlsHidden,
|
||||
child: ExcludeSemantics(
|
||||
excluding: showLyrics && _lyricsControlsHidden,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showLyrics && _lyricsControlsHidden ? 0 : 1,
|
||||
duration: motion,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
controls(),
|
||||
SizedBox(height: volumeGap - transportShift),
|
||||
const MornyeVolumeControl(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Controls keep their intrinsic height. Small portrait screens and
|
||||
// large accessibility text can scroll instead of clipping the volume.
|
||||
final minHeight =
|
||||
(showLyrics && _lyricsControlsHidden ? 250.0 : 480.0) +
|
||||
(scale - 1).clamp(0, 3) * 120;
|
||||
if (constraints.maxHeight < minHeight) {
|
||||
return SingleChildScrollView(
|
||||
child: SizedBox(height: minHeight, child: content()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final volumeGap =
|
||||
8 +
|
||||
(squareArtwork
|
||||
? (constraints.maxHeight - 480).clamp(0.0, 48.0)
|
||||
: 0);
|
||||
// Share the spare space above and below the transport row so it sits
|
||||
// between the timeline and volume without moving either slider.
|
||||
final transportShift = landscape
|
||||
? 0.0
|
||||
: ((volumeGap - 16) / 2).clamp(0.0, 20.0);
|
||||
Widget controls() => _PlaybackControls(
|
||||
mediaId: mediaItem.id,
|
||||
duration: mediaItem.duration ?? Duration.zero,
|
||||
controller: controller,
|
||||
colorScheme: colorScheme,
|
||||
qualityLabel: _qualityLabel(),
|
||||
compact: landscape,
|
||||
transportTopPadding: 16 + transportShift,
|
||||
);
|
||||
|
||||
if (landscape) {
|
||||
return MornyeLandscapePlayer(
|
||||
artwork: stage(artworkOnly: true),
|
||||
header: _trackHeader(mediaItem, colorScheme, compact: true),
|
||||
lyrics: _lyricsSection(colorScheme, isActive: true),
|
||||
queue: MornyePlayerQueue(
|
||||
colorScheme: colorScheme,
|
||||
onShuffleLibrary: () => _shuffleLibrary(controller),
|
||||
),
|
||||
controls: controls(),
|
||||
volume: const MornyeVolumeControl(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget content() => Column(
|
||||
children: [
|
||||
Expanded(child: stage()),
|
||||
AnimatedSize(
|
||||
duration: motion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
child: compactStage
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 12, 28, 8),
|
||||
child: _trackHeader(mediaItem, colorScheme),
|
||||
),
|
||||
),
|
||||
controls(),
|
||||
SizedBox(height: volumeGap - transportShift),
|
||||
const MornyeVolumeControl(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
|
||||
// Controls keep their intrinsic height. Small portrait screens and
|
||||
// large accessibility text can scroll instead of clipping the volume.
|
||||
final minHeight = 480.0 + (scale - 1).clamp(0, 3) * 120;
|
||||
if (constraints.maxHeight < minHeight) {
|
||||
return SingleChildScrollView(
|
||||
child: SizedBox(height: minHeight, child: content()),
|
||||
);
|
||||
}
|
||||
return content();
|
||||
},
|
||||
}
|
||||
return content();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1991,6 +2090,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
bool _hasStarted = false;
|
||||
bool _userScrolling = false;
|
||||
static const double _estimatedLyricExtent = 64;
|
||||
List<double>? _lineExtents;
|
||||
Object? _lineLayoutKey;
|
||||
double? _viewportHeight;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -2018,6 +2120,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
|
||||
void _resetLineKeys() {
|
||||
_hasStarted = false;
|
||||
_lineExtents = null;
|
||||
_lineLayoutKey = null;
|
||||
_lineKeys = List<GlobalKey>.generate(
|
||||
widget.lyrics.lines.length,
|
||||
(index) => GlobalKey(debugLabel: 'lyric-line-$index'),
|
||||
@@ -2034,6 +2138,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
_playingSubscription = null;
|
||||
_loadingSubscription = null;
|
||||
if (!widget.isActive) return;
|
||||
_userScrollIdleTimer?.cancel();
|
||||
_userScrolling = false;
|
||||
|
||||
final position = ref.read(playbackPositionProvider);
|
||||
_playing = ref.read(playbackPlayingProvider);
|
||||
@@ -2041,7 +2147,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
_active = _activeIndexAt(position);
|
||||
_activeTransitionPosition = position;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_maybeAutoScroll(_active));
|
||||
if (mounted) unawaited(_maybeAutoScroll(_active, immediate: true));
|
||||
});
|
||||
_scheduleNextLine(position);
|
||||
_positionSubscription = ref.listenManual<Duration>(
|
||||
@@ -2133,8 +2239,64 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _maybeAutoScroll(int index) async {
|
||||
void _measureMornyeLines(double width) {
|
||||
final style = Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
height: 1.3,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
final scaler = MediaQuery.textScalerOf(context);
|
||||
final direction = Directionality.of(context);
|
||||
final locale = Localizations.maybeLocaleOf(context);
|
||||
final key = (width, style, scaler, direction, locale);
|
||||
if (_lineLayoutKey == key) return;
|
||||
_lineLayoutKey = key;
|
||||
final painter = TextPainter(
|
||||
textDirection: direction,
|
||||
textScaler: scaler,
|
||||
locale: locale,
|
||||
);
|
||||
final extents = <double>[];
|
||||
for (final line in widget.lyrics.lines) {
|
||||
painter.text = TextSpan(
|
||||
text: line.text.trim().isEmpty ? '\u00b7\u00b7\u00b7' : line.text,
|
||||
style: style,
|
||||
);
|
||||
painter.layout(maxWidth: width);
|
||||
extents.add(painter.height + 32);
|
||||
}
|
||||
_lineExtents = extents;
|
||||
painter.dispose();
|
||||
}
|
||||
|
||||
Future<void> _maybeAutoScroll(int index, {bool immediate = false}) async {
|
||||
if (_userScrolling || index < 0 || !_scroll.hasClients) return;
|
||||
final extents = _lineExtents;
|
||||
if (context.isMornye && extents != null && index < extents.length) {
|
||||
final position = _scroll.position;
|
||||
final padding = syncedLyricsCenterPadding(
|
||||
viewportDimension: position.viewportDimension,
|
||||
estimatedLineExtent: _estimatedLyricExtent,
|
||||
);
|
||||
final target =
|
||||
extents.take(index).fold(0.0, (sum, extent) => sum + extent) +
|
||||
padding -
|
||||
(position.viewportDimension - extents[index]) / 2;
|
||||
final offset = target.clamp(
|
||||
position.minScrollExtent,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
if (immediate || MediaQuery.disableAnimationsOf(context)) {
|
||||
_scroll.jumpTo(offset);
|
||||
} else {
|
||||
await _scroll.animateTo(
|
||||
offset,
|
||||
duration: const Duration(milliseconds: 380),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (index < _lineKeys.length) {
|
||||
final lineContext = _lineKeys[index].currentContext;
|
||||
if (lineContext != null) {
|
||||
@@ -2197,19 +2359,37 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
||||
_userScrolling = true;
|
||||
_userScrollIdleTimer?.cancel();
|
||||
_userScrollIdleTimer = Timer(const Duration(seconds: 4), () {
|
||||
if (mounted) _userScrolling = false;
|
||||
if (!mounted) return;
|
||||
_userScrolling = false;
|
||||
unawaited(_maybeAutoScroll(_active));
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (mornye) {
|
||||
_measureMornyeLines(
|
||||
(constraints.maxWidth - 48).clamp(0, double.infinity),
|
||||
);
|
||||
if (_viewportHeight != constraints.maxHeight) {
|
||||
_viewportHeight = constraints.maxHeight;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
unawaited(_maybeAutoScroll(_active, immediate: true));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
final centerPadding = syncedLyricsCenterPadding(
|
||||
viewportDimension: constraints.maxHeight,
|
||||
estimatedLineExtent: _estimatedLyricExtent,
|
||||
);
|
||||
return ListView.builder(
|
||||
controller: _scroll,
|
||||
itemExtentBuilder: mornye
|
||||
? (index, _) => _lineExtents![index]
|
||||
: null,
|
||||
padding: EdgeInsets.fromLTRB(24, centerPadding, 24, centerPadding),
|
||||
itemCount: lines.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
||||
@@ -14,6 +14,8 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
|
||||
import 'package:spotiflac_android/services/motion_artwork_store.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
@@ -72,6 +74,9 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
final tempDirFuture = getTemporaryDirectory();
|
||||
final appSupportDirFuture = getApplicationSupportDirectory();
|
||||
final coverStatsFuture = CoverCacheManager.getStats();
|
||||
final motionStatsFuture = MotionArtworkStore.defaultDirectory().then(
|
||||
_scanDirectory,
|
||||
);
|
||||
final prefsFuture = SharedPreferences.getInstance();
|
||||
final trackCacheEntriesFuture = _getTrackCacheSizeSafe();
|
||||
|
||||
@@ -120,6 +125,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
tempStats: tempStats,
|
||||
tempIsSameAsAppCache: tempIsSameAsAppCache,
|
||||
coverStats: coverStats,
|
||||
motionStats: await motionStatsFuture,
|
||||
libraryCoverStats: libraryCoverStats,
|
||||
audioAnalysisStats: audioAnalysisStats,
|
||||
exploreCacheBytes: exploreBytes,
|
||||
@@ -205,6 +211,11 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
await CoverCacheManager.clearCache();
|
||||
}
|
||||
|
||||
Future<void> _clearMotionArtwork() async {
|
||||
await ref.read(motionArtworkStoreProvider).clear();
|
||||
if (mounted) ref.invalidate(playerMotionArtworkProvider);
|
||||
}
|
||||
|
||||
Future<void> _clearLibraryCoverCache() async {
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
|
||||
@@ -624,6 +635,24 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.video_library_outlined,
|
||||
title: context.l10n.cacheMotionArtwork,
|
||||
subtitle: _buildSubtitle(
|
||||
context.l10n.cacheMotionArtworkDesc,
|
||||
overview.motionStats.totalSizeBytes > 0
|
||||
? formatBytes(overview.motionStats.totalSizeBytes)
|
||||
: context.l10n.cacheNoData,
|
||||
),
|
||||
trailing: _buildClearTrailing(
|
||||
'clear_motion_artwork',
|
||||
() => _confirmAndRunAction(
|
||||
actionKey: 'clear_motion_artwork',
|
||||
targetLabel: context.l10n.cacheMotionArtwork,
|
||||
action: _clearMotionArtwork,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.explore_outlined,
|
||||
title: context.l10n.cacheExploreFeed,
|
||||
@@ -707,6 +736,7 @@ class _CacheOverview {
|
||||
final _DirectoryStats? tempStats;
|
||||
final bool tempIsSameAsAppCache;
|
||||
final CacheStats coverStats;
|
||||
final _DirectoryStats motionStats;
|
||||
final _DirectoryStats libraryCoverStats;
|
||||
final _DirectoryStats audioAnalysisStats;
|
||||
final int exploreCacheBytes;
|
||||
@@ -720,6 +750,7 @@ class _CacheOverview {
|
||||
this.tempStats,
|
||||
required this.tempIsSameAsAppCache,
|
||||
required this.coverStats,
|
||||
required this.motionStats,
|
||||
required this.libraryCoverStats,
|
||||
required this.audioAnalysisStats,
|
||||
required this.exploreCacheBytes,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
typedef MotionArtworkAlbum = ({String album, String artist});
|
||||
|
||||
class MotionArtwork {
|
||||
const MotionArtwork(this.source, {this.aspectRatio});
|
||||
|
||||
final String source;
|
||||
final double? aspectRatio;
|
||||
}
|
||||
|
||||
/// Durable, album-scoped artwork. Relative filenames survive iOS reinstalls
|
||||
/// that relocate the app container; temporary cache cleanup does not remove it.
|
||||
class MotionArtworkStore {
|
||||
MotionArtworkStore({
|
||||
Future<Directory> Function()? directory,
|
||||
Future<double?> Function(String source, String output)? download,
|
||||
}) : _directory = directory ?? defaultDirectory,
|
||||
_download = download ?? _downloadVideo;
|
||||
|
||||
final Future<Directory> Function() _directory;
|
||||
final Future<double?> Function(String source, String output) _download;
|
||||
final _pending = <String, Future<MotionArtwork?>>{};
|
||||
bool _clearing = false;
|
||||
static final _log = AppLogger('MotionArtwork');
|
||||
|
||||
static Future<Directory> defaultDirectory() async => Directory(
|
||||
'${(await getApplicationSupportDirectory()).path}/motion_artwork',
|
||||
);
|
||||
|
||||
String _key(MotionArtworkAlbum album) {
|
||||
String normalize(String value) =>
|
||||
value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||
return sha256
|
||||
.convert(
|
||||
utf8.encode(
|
||||
jsonEncode([normalize(album.album), normalize(album.artist)]),
|
||||
),
|
||||
)
|
||||
.toString();
|
||||
}
|
||||
|
||||
Future<MotionArtwork?> find(MotionArtworkAlbum album) async {
|
||||
try {
|
||||
final root = await _directory();
|
||||
final key = _key(album);
|
||||
final file = File('${root.path}/$key.mp4');
|
||||
if (!await file.exists() || await file.length() == 0) return null;
|
||||
double? ratio;
|
||||
final info = File('${root.path}/$key.json');
|
||||
if (await info.exists()) {
|
||||
final data = jsonDecode(await info.readAsString());
|
||||
if (data is Map && data['aspectRatio'] is num) {
|
||||
final value = (data['aspectRatio'] as num).toDouble();
|
||||
if (value.isFinite && value > 0) ratio = value;
|
||||
}
|
||||
}
|
||||
return MotionArtwork(file.uri.toString(), aspectRatio: ratio);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<MotionArtwork?> save(
|
||||
MotionArtworkAlbum album, {
|
||||
required Future<String?> Function() resolveSource,
|
||||
}) {
|
||||
if (_clearing ||
|
||||
album.album.trim().isEmpty ||
|
||||
album.artist.trim().isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
final key = _key(album);
|
||||
return _pending.putIfAbsent(key, () async {
|
||||
try {
|
||||
return await _save(album, key, resolveSource);
|
||||
} finally {
|
||||
_pending.remove(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_clearing = true;
|
||||
try {
|
||||
await Future.wait(_pending.values.toList());
|
||||
final root = await _directory();
|
||||
if (await root.exists()) await root.delete(recursive: true);
|
||||
} finally {
|
||||
_clearing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<MotionArtwork?> _save(
|
||||
MotionArtworkAlbum album,
|
||||
String key,
|
||||
Future<String?> Function() resolveSource,
|
||||
) async {
|
||||
File? temporary;
|
||||
File? infoTemporary;
|
||||
try {
|
||||
final existing = await find(album);
|
||||
if (existing != null) return existing;
|
||||
final source = normalizeRemoteHttpUrl(await resolveSource());
|
||||
if (source == null) return null;
|
||||
final root = await _directory();
|
||||
await root.create(recursive: true);
|
||||
temporary = File('${root.path}/$key.partial.mp4');
|
||||
final ratio = await _download(source, temporary.path);
|
||||
if (ratio == null ||
|
||||
!ratio.isFinite ||
|
||||
ratio <= 0 ||
|
||||
!await temporary.exists() ||
|
||||
await temporary.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
infoTemporary = File('${root.path}/$key.partial.json');
|
||||
await infoTemporary.writeAsString(
|
||||
jsonEncode({'aspectRatio': ratio}),
|
||||
flush: true,
|
||||
);
|
||||
final file = await temporary.rename('${root.path}/$key.mp4');
|
||||
await infoTemporary.rename('${root.path}/$key.json');
|
||||
return MotionArtwork(file.uri.toString(), aspectRatio: ratio);
|
||||
} catch (error) {
|
||||
_log.w('Could not save optional motion artwork: ${error.runtimeType}');
|
||||
return null;
|
||||
} finally {
|
||||
for (final file in [temporary, infoTemporary]) {
|
||||
try {
|
||||
if (file != null && await file.exists()) await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<double?> _downloadVideo(String source, String output) async {
|
||||
final completed = Completer<bool>();
|
||||
// Remux public video/HLS into a self-contained, silent MP4. Bound both
|
||||
// transfer time and output size; never let optional artwork block audio.
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(
|
||||
[
|
||||
'-y',
|
||||
'-protocol_whitelist',
|
||||
'http,https,tcp,tls,crypto',
|
||||
'-rw_timeout',
|
||||
'10000000',
|
||||
'-i',
|
||||
source,
|
||||
'-map',
|
||||
'0:v:0',
|
||||
'-an',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-c:v',
|
||||
'copy',
|
||||
'-t',
|
||||
'30',
|
||||
'-fs',
|
||||
'25165824',
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
output,
|
||||
],
|
||||
(session) async {
|
||||
final success = ReturnCode.isSuccess(await session.getReturnCode());
|
||||
if (!completed.isCompleted) completed.complete(success);
|
||||
},
|
||||
);
|
||||
final success = await completed.future.timeout(
|
||||
const Duration(seconds: 60),
|
||||
onTimeout: () async {
|
||||
await FFmpegKit.cancel(session.getSessionId());
|
||||
// Wait for this session to release its file before removing partials.
|
||||
await completed.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => false,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
if (!success) return null;
|
||||
final dimensions = await FFmpegService.probeImageDimensions(output);
|
||||
return dimensions == null ? null : dimensions.width / dimensions.height;
|
||||
}
|
||||
}
|
||||
@@ -869,7 +869,8 @@ class HomeSearchSkeleton extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||
if (!mornye)
|
||||
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -893,24 +894,19 @@ class HomeSearchSkeleton extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (mornye)
|
||||
for (var index = 0; index < rows.length; index++)
|
||||
Container(
|
||||
margin: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
index == rows.length - 1 ? 0 : 8,
|
||||
),
|
||||
decoration: decoration,
|
||||
child: rows[index],
|
||||
)
|
||||
else
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: decoration,
|
||||
child: Column(children: rows),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: decoration,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var index = 0; index < rows.length; index++) ...[
|
||||
if (mornye && index > 0)
|
||||
const Divider(height: 1, indent: 80, endIndent: 12),
|
||||
rows[index],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ class MornyeGlassPanel extends ConsumerWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
this.radius = 32,
|
||||
this.firstInGroup = true,
|
||||
this.lastInGroup = true,
|
||||
this.strongTint = false,
|
||||
this.tintOpacity,
|
||||
});
|
||||
@@ -132,17 +134,23 @@ class MornyeGlassPanel extends ConsumerWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
this.radius = 32,
|
||||
this.firstInGroup = true,
|
||||
this.lastInGroup = true,
|
||||
this.tintOpacity = 0.78,
|
||||
}) : strongTint = false;
|
||||
|
||||
final Widget child;
|
||||
final double radius;
|
||||
final bool firstInGroup;
|
||||
final bool lastInGroup;
|
||||
final bool strongTint;
|
||||
final double? tintOpacity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation(
|
||||
radius: radius,
|
||||
firstInGroup: firstInGroup,
|
||||
lastInGroup: lastInGroup,
|
||||
strongTint: strongTint,
|
||||
tintOpacity: tintOpacity,
|
||||
blurEnabled:
|
||||
|
||||
@@ -83,7 +83,7 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _reveal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: widget.artwork),
|
||||
@@ -91,7 +91,7 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||
child: widget.header,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -8,20 +8,30 @@ class MornyePlayerArtwork extends StatelessWidget {
|
||||
super.key,
|
||||
required this.mediaItem,
|
||||
this.videoUrl,
|
||||
this.onAspectRatioChanged,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
final MediaItem mediaItem;
|
||||
final String? videoUrl;
|
||||
final ValueChanged<double>? onAspectRatioChanged;
|
||||
final VoidCallback? onError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fallback = PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
cacheWidth:
|
||||
(MediaQuery.sizeOf(context).width *
|
||||
MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
final fallback = Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
cacheWidth:
|
||||
(MediaQuery.sizeOf(context).width *
|
||||
MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (MediaQuery.disableAnimationsOf(context)) return fallback;
|
||||
return videoUrl == null
|
||||
@@ -30,6 +40,8 @@ class MornyePlayerArtwork extends StatelessWidget {
|
||||
key: ValueKey(videoUrl),
|
||||
videoUrl: videoUrl!,
|
||||
fallback: fallback,
|
||||
onAspectRatioChanged: onAspectRatioChanged,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ class MornyePlayerBackground extends StatelessWidget {
|
||||
required this.artUri,
|
||||
this.artwork,
|
||||
this.squareArtwork = true,
|
||||
this.artworkAspectRatio,
|
||||
});
|
||||
|
||||
final Uri? artUri;
|
||||
final Widget? artwork;
|
||||
final bool squareArtwork;
|
||||
final double? artworkAspectRatio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -56,11 +58,19 @@ class MornyePlayerBackground extends StatelessWidget {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned(
|
||||
AnimatedPositioned(
|
||||
duration: motion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: squareArtwork
|
||||
height: artworkAspectRatio != null && artworkAspectRatio! > 0
|
||||
? (MediaQuery.sizeOf(context).width / artworkAspectRatio!)
|
||||
.clamp(
|
||||
0.0,
|
||||
MediaQuery.sizeOf(context).height * 0.75,
|
||||
)
|
||||
: squareArtwork
|
||||
? MediaQuery.sizeOf(context).width
|
||||
: MediaQuery.sizeOf(context).height * 0.66,
|
||||
child: ShaderMask(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
@@ -10,6 +12,8 @@ class MotionHeaderBanner extends StatefulWidget {
|
||||
final Widget fallback;
|
||||
final BoxFit fit;
|
||||
final Alignment alignment;
|
||||
final ValueChanged<double>? onAspectRatioChanged;
|
||||
final VoidCallback? onError;
|
||||
|
||||
const MotionHeaderBanner({
|
||||
super.key,
|
||||
@@ -17,6 +21,8 @@ class MotionHeaderBanner extends StatefulWidget {
|
||||
required this.fallback,
|
||||
this.fit = BoxFit.cover,
|
||||
this.alignment = Alignment.topCenter,
|
||||
this.onAspectRatioChanged,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -95,28 +101,35 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
|
||||
return;
|
||||
}
|
||||
|
||||
final controller = VideoPlayerController.networkUrl(
|
||||
Uri.parse(url),
|
||||
formatHint: Uri.parse(url).path.toLowerCase().endsWith('.m3u8')
|
||||
? VideoFormat.hls
|
||||
: null,
|
||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
|
||||
);
|
||||
final uri = Uri.tryParse(url);
|
||||
final options = VideoPlayerOptions(mixWithOthers: true);
|
||||
final controller = uri?.scheme == 'file' || url.startsWith('/')
|
||||
? VideoPlayerController.file(
|
||||
uri?.scheme == 'file' ? File.fromUri(uri!) : File(url),
|
||||
videoPlayerOptions: options,
|
||||
)
|
||||
: VideoPlayerController.networkUrl(
|
||||
Uri.parse(url),
|
||||
formatHint: uri?.path.toLowerCase().endsWith('.m3u8') == true
|
||||
? VideoFormat.hls
|
||||
: null,
|
||||
videoPlayerOptions: options,
|
||||
);
|
||||
_controller = controller;
|
||||
try {
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
if (!mounted || !identical(controller, _controller)) return;
|
||||
await controller.setVolume(0);
|
||||
await controller.setLooping(true);
|
||||
if (!mounted || !identical(controller, _controller)) return;
|
||||
setState(() => _ready = true);
|
||||
widget.onAspectRatioChanged?.call(controller.value.aspectRatio);
|
||||
_syncPlayback();
|
||||
} catch (e) {
|
||||
_log.w('Failed to play motion banner: $e');
|
||||
if (!mounted) return;
|
||||
if (!mounted || !identical(controller, _controller)) return;
|
||||
setState(() => _failed = true);
|
||||
widget.onError?.call();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user