mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
fix(player): preserve offline motion artwork and smooth cover changes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_queue.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_background.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_playback_button.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_playback_time.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_actions_sheet.dart';
|
||||
@@ -223,6 +224,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
String? _failedMotionSource;
|
||||
bool _lyricsControlsHidden = false;
|
||||
double _lyricsScrollDistance = 0;
|
||||
final _artworkHeaderKey = GlobalKey();
|
||||
final _artworkControlsKey = GlobalKey();
|
||||
final _artworkVolumeKey = GlobalKey();
|
||||
Map<String, Color> _artworkForeground = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -412,16 +417,15 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
}
|
||||
|
||||
final source = mediaItem.extras?['source']?.toString() ?? '';
|
||||
final resolvedMotion = mornye && !MediaQuery.disableAnimationsOf(context)
|
||||
? ref
|
||||
.watch(
|
||||
playerMotionArtworkProvider((
|
||||
album: mediaItem.album ?? '',
|
||||
artist: mediaItem.artist ?? '',
|
||||
)),
|
||||
)
|
||||
.value
|
||||
final motionState = mornye && !MediaQuery.disableAnimationsOf(context)
|
||||
? ref.watch(
|
||||
playerMotionArtworkProvider((
|
||||
album: mediaItem.album ?? '',
|
||||
artist: mediaItem.artist ?? '',
|
||||
)),
|
||||
)
|
||||
: null;
|
||||
final resolvedMotion = motionState?.value;
|
||||
final motionArtwork = resolvedMotion?.source == _failedMotionSource
|
||||
? null
|
||||
: resolvedMotion;
|
||||
@@ -432,9 +436,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
: null);
|
||||
final squareArtwork =
|
||||
motionArtwork == null || (motionRatio != null && motionRatio >= 0.95);
|
||||
if (motionArtwork == null) _artworkForeground = {};
|
||||
Widget artwork() => MornyePlayerArtwork(
|
||||
mediaItem: mediaItem,
|
||||
videoUrl: motionArtwork?.source,
|
||||
resolvingVideo: motionState?.isLoading == true,
|
||||
onAspectRatioChanged: (ratio) {
|
||||
if (!mounted ||
|
||||
(_measuredMotionSource == motionArtwork?.source &&
|
||||
@@ -536,7 +542,6 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
mediaItem,
|
||||
controller,
|
||||
colorScheme,
|
||||
squareArtwork: squareArtwork,
|
||||
motionArtwork: artwork(),
|
||||
artworkAspectRatio: motionRatio,
|
||||
)
|
||||
@@ -632,7 +637,19 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
MornyeArtworkContrast(
|
||||
enabled:
|
||||
motionArtwork != null &&
|
||||
!_landscapeLyrics &&
|
||||
_currentPage == 0,
|
||||
targets: {
|
||||
'header': _artworkHeaderKey,
|
||||
'controls': _artworkControlsKey,
|
||||
'volume': _artworkVolumeKey,
|
||||
},
|
||||
onChanged: (colors) {
|
||||
if (mounted) setState(() => _artworkForeground = colors);
|
||||
},
|
||||
child: MornyePlayerBackground(
|
||||
artUri: mediaItem.artUri,
|
||||
squareArtwork: squareArtwork,
|
||||
@@ -824,13 +841,22 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
MediaItem mediaItem,
|
||||
MusicPlayerController controller,
|
||||
ColorScheme colorScheme, {
|
||||
required bool squareArtwork,
|
||||
required Widget motionArtwork,
|
||||
double? artworkAspectRatio,
|
||||
}) {
|
||||
final showLyrics = _landscapeLyrics || _currentPage == 1;
|
||||
final showQueue = !_landscapeLyrics && _currentPage == 2;
|
||||
final compactStage = showLyrics || showQueue;
|
||||
ColorScheme foreground(String region) {
|
||||
final color = compactStage ? null : _artworkForeground[region];
|
||||
return color == null
|
||||
? colorScheme
|
||||
: colorScheme.copyWith(
|
||||
onSurface: color,
|
||||
onSurfaceVariant: color.withValues(alpha: 0.72),
|
||||
);
|
||||
}
|
||||
|
||||
final motion = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 380);
|
||||
@@ -1008,21 +1034,20 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
},
|
||||
);
|
||||
|
||||
final volumeGap =
|
||||
8 +
|
||||
(squareArtwork
|
||||
? (constraints.maxHeight - 480).clamp(0.0, 48.0)
|
||||
: 0);
|
||||
// Portrait video must not push the metadata/transport below the
|
||||
// square-cover position. Leave room above the anchored volume row.
|
||||
final volumeGap = 8 + (constraints.maxHeight - 440).clamp(0.0, 64.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(
|
||||
key: _artworkControlsKey,
|
||||
mediaId: mediaItem.id,
|
||||
duration: mediaItem.duration ?? Duration.zero,
|
||||
controller: controller,
|
||||
colorScheme: colorScheme,
|
||||
colorScheme: foreground('controls'),
|
||||
qualityLabel: _qualityLabel(),
|
||||
compact: landscape,
|
||||
transportTopPadding: 16 + transportShift,
|
||||
@@ -1051,8 +1076,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
child: compactStage
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
key: _artworkHeaderKey,
|
||||
padding: const EdgeInsets.fromLTRB(28, 12, 28, 8),
|
||||
child: _trackHeader(mediaItem, colorScheme),
|
||||
child: _trackHeader(mediaItem, foreground('header')),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
@@ -1074,7 +1100,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
children: [
|
||||
controls(),
|
||||
SizedBox(height: volumeGap - transportShift),
|
||||
const MornyeVolumeControl(),
|
||||
MornyeVolumeControl(
|
||||
key: _artworkVolumeKey,
|
||||
foreground: foreground('volume').onSurface,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
@@ -1142,7 +1171,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 13 : 20,
|
||||
color: Colors.white70,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1154,6 +1183,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
key: ValueKey(mediaItem.id),
|
||||
mediaItem: mediaItem,
|
||||
compact: compact,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
Builder(
|
||||
builder: (buttonContext) => IconButton(
|
||||
@@ -1878,11 +1908,15 @@ class _PlaybackControls extends ConsumerWidget {
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 4,
|
||||
activeTrackColor: mornye ? Colors.white : colorScheme.primary,
|
||||
activeTrackColor: mornye
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.primary,
|
||||
inactiveTrackColor: colorScheme.onSurface.withValues(
|
||||
alpha: 0.18,
|
||||
),
|
||||
thumbColor: mornye ? Colors.white : colorScheme.primary,
|
||||
thumbColor: mornye
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.primary,
|
||||
// A 7dp thumb was hard to grab; 10dp with a 24dp overlay
|
||||
// gives the drag gesture a full-size target.
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
/// Resolve optional artwork while downloading, using only the original
|
||||
/// metadata extension. Search results may omit artwork held by the album.
|
||||
Future<String?> resolveDownloadMotionArtworkSource({
|
||||
required Track original,
|
||||
required Track downloaded,
|
||||
required Map<String, dynamic> result,
|
||||
required Future<Map<String, dynamic>> Function(
|
||||
String provider,
|
||||
String type,
|
||||
String id,
|
||||
)
|
||||
getMetadata,
|
||||
}) async {
|
||||
String? video(Map<dynamic, dynamic> data) =>
|
||||
normalizeRemoteHttpUrl(data['header_video']?.toString());
|
||||
|
||||
for (final candidate in [
|
||||
original.headerVideoUrl,
|
||||
downloaded.headerVideoUrl,
|
||||
result['header_video']?.toString(),
|
||||
]) {
|
||||
final url = normalizeRemoteHttpUrl(candidate);
|
||||
if (url != null) return url;
|
||||
}
|
||||
|
||||
final provider = resolvePreferredMetadataProviderId(
|
||||
original.source,
|
||||
original.id,
|
||||
);
|
||||
if (provider == null) return null;
|
||||
|
||||
String resourceId(String id) =>
|
||||
id.startsWith('$provider:') ? id.substring(provider.length + 1) : id;
|
||||
Future<Map<String, dynamic>> metadata(String type, String id) => getMetadata(
|
||||
provider,
|
||||
type,
|
||||
resourceId(id),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
|
||||
var albumId = normalizeOptionalString(original.albumId);
|
||||
if (albumId == null) {
|
||||
final trackId = normalizeOptionalString(original.id);
|
||||
if (trackId == null) return null;
|
||||
final response = await metadata('track', trackId);
|
||||
final track = response['track'];
|
||||
if (track is! Map) return null;
|
||||
final url = video(track);
|
||||
if (url != null) return url;
|
||||
albumId = normalizeOptionalString(track['album_id']?.toString());
|
||||
}
|
||||
if (albumId == null) return null;
|
||||
final response = await metadata('album', albumId);
|
||||
final album = response['album_info'] ?? response['album'];
|
||||
return (album is Map ? video(album) : null) ?? video(response);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
/// Fetch a single-file HLS cover as one complete HTTP response. Passing its
|
||||
/// byte-range playlist straight to FFmpeg can splice truncated segments into
|
||||
/// an MP4 that still reports a successful remux.
|
||||
Future<File?> downloadMotionArtworkSource(String source, String output) async {
|
||||
final uri = Uri.parse(source);
|
||||
if (!uri.path.toLowerCase().endsWith('.m3u8')) return null;
|
||||
const maxBytes = 24 << 20;
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
||||
final deadline = Timer(
|
||||
const Duration(seconds: 30),
|
||||
() => client.close(force: true),
|
||||
);
|
||||
final file = File(output);
|
||||
var complete = false;
|
||||
try {
|
||||
Future<({Uri uri, String text})> playlist(Uri url) async {
|
||||
final response = await _get(client, url);
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in response.timeout(const Duration(seconds: 8))) {
|
||||
if (bytes.length + chunk.length > 512 << 10) {
|
||||
throw const FormatException('Artwork playlist exceeds size limit');
|
||||
}
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
for (final redirect in response.redirects) {
|
||||
url = url.resolveUri(redirect.location);
|
||||
}
|
||||
return (uri: url, text: utf8.decode(bytes));
|
||||
}
|
||||
|
||||
var media = await playlist(uri);
|
||||
final variant = _videoVariant(media.text, media.uri);
|
||||
if (variant != null) media = await playlist(variant);
|
||||
final video = _singleFileVideo(media.text, media.uri);
|
||||
if (video == null || video.length > maxBytes) return null;
|
||||
final response = await _get(client, video.uri);
|
||||
if (response.contentLength > maxBytes) {
|
||||
throw const FormatException('Artwork video exceeds size limit');
|
||||
}
|
||||
final sink = await file.open(mode: FileMode.write);
|
||||
var length = 0;
|
||||
try {
|
||||
await for (final chunk in response.timeout(const Duration(seconds: 8))) {
|
||||
length += chunk.length;
|
||||
if (length > maxBytes) {
|
||||
throw const FormatException('Artwork video exceeds size limit');
|
||||
}
|
||||
await sink.writeFrom(chunk);
|
||||
}
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
if (length < video.length ||
|
||||
(response.contentLength >= 0 && length != response.contentLength)) {
|
||||
throw const FormatException('Incomplete artwork video');
|
||||
}
|
||||
complete = true;
|
||||
return file;
|
||||
} finally {
|
||||
deadline.cancel();
|
||||
client.close(force: true);
|
||||
if (!complete && await file.exists()) await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<HttpClientResponse> _get(HttpClient client, Uri uri) async {
|
||||
final request = await client.getUrl(uri).timeout(const Duration(seconds: 8));
|
||||
final response = await request.close().timeout(const Duration(seconds: 8));
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw HttpException('Artwork HTTP ${response.statusCode}');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Uri? _remoteUri(Uri base, String? value) {
|
||||
if (value == null) return null;
|
||||
final normalized = normalizeRemoteHttpUrl(base.resolve(value).toString());
|
||||
return normalized == null ? null : Uri.parse(normalized);
|
||||
}
|
||||
|
||||
Uri? _videoVariant(String playlist, Uri base) {
|
||||
var video = false;
|
||||
for (final raw in const LineSplitter().convert(playlist)) {
|
||||
final line = raw.trim();
|
||||
if (line.startsWith('#EXT-X-STREAM-INF:')) {
|
||||
// Prefer broadly supported H.264, never an I-frame preview playlist.
|
||||
video = line.contains('avc1.') || line.contains('avc3.');
|
||||
} else if (line.isNotEmpty && !line.startsWith('#')) {
|
||||
if (video) return _remoteUri(base, line);
|
||||
video = false;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
({Uri uri, int length})? _singleFileVideo(String playlist, Uri base) {
|
||||
final lines = const LineSplitter()
|
||||
.convert(playlist)
|
||||
.map((line) => line.trim());
|
||||
if (!lines.contains('#EXTM3U') || !lines.contains('#EXT-X-ENDLIST')) {
|
||||
return null;
|
||||
}
|
||||
Uri? video;
|
||||
var offset = 0;
|
||||
var segments = 0;
|
||||
String? range;
|
||||
for (final line in lines) {
|
||||
if (line.startsWith('#EXT-X-KEY:') &&
|
||||
!RegExp(r'(?:[:,])METHOD=NONE(?:,|$)').hasMatch(line)) {
|
||||
return null;
|
||||
}
|
||||
if (line.startsWith('#EXT-X-DISCONTINUITY') ||
|
||||
line.startsWith('#EXT-X-STREAM-INF:')) {
|
||||
return null;
|
||||
}
|
||||
if (line.startsWith('#EXT-X-MAP:')) {
|
||||
if (video != null || segments != 0) return null;
|
||||
video = _remoteUri(
|
||||
base,
|
||||
RegExp(r'(?:[:,])URI="([^"]+)"').firstMatch(line)?.group(1),
|
||||
);
|
||||
final initial = RegExp(r'(?:[:,])BYTERANGE="(\d+)@0"').firstMatch(line);
|
||||
offset = int.tryParse(initial?.group(1) ?? '') ?? 0;
|
||||
if (video == null || offset <= 0) return null;
|
||||
} else if (line.startsWith('#EXT-X-BYTERANGE:')) {
|
||||
if (range != null) return null;
|
||||
range = line.substring('#EXT-X-BYTERANGE:'.length);
|
||||
} else if (line.isNotEmpty && !line.startsWith('#')) {
|
||||
if (video == null || _remoteUri(base, line) != video || range == null) {
|
||||
return null;
|
||||
}
|
||||
final match = RegExp(r'^(\d+)(?:@(\d+))?$').firstMatch(range);
|
||||
final length = int.tryParse(match?.group(1) ?? '') ?? 0;
|
||||
final start = match?.group(2);
|
||||
if (length <= 0 || (start != null && int.tryParse(start) != offset)) {
|
||||
return null;
|
||||
}
|
||||
offset += length;
|
||||
range = null;
|
||||
segments++;
|
||||
}
|
||||
}
|
||||
return video == null || segments == 0 || range != null
|
||||
? null
|
||||
: (uri: video, length: offset);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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/services/motion_artwork_download.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
@@ -25,14 +26,20 @@ class MotionArtworkStore {
|
||||
MotionArtworkStore({
|
||||
Future<Directory> Function()? directory,
|
||||
Future<double?> Function(String source, String output)? download,
|
||||
Future<bool> Function(String path)? validate,
|
||||
}) : _directory = directory ?? defaultDirectory,
|
||||
_download = download ?? _downloadVideo;
|
||||
_download = download ?? _downloadVideo,
|
||||
_validate = validate ?? _validateVideo;
|
||||
|
||||
final Future<Directory> Function() _directory;
|
||||
final Future<double?> Function(String source, String output) _download;
|
||||
final Future<bool> Function(String path) _validate;
|
||||
final _pending = <String, Future<MotionArtwork?>>{};
|
||||
final _legacyChecks = <String, Future<bool>>{};
|
||||
final _invalid = <String>{};
|
||||
bool _clearing = false;
|
||||
static final _log = AppLogger('MotionArtwork');
|
||||
static const _cacheVersion = 2;
|
||||
|
||||
static Future<Directory> defaultDirectory() async => Directory(
|
||||
'${(await getApplicationSupportDirectory()).path}/motion_artwork',
|
||||
@@ -51,20 +58,40 @@ class MotionArtworkStore {
|
||||
}
|
||||
|
||||
Future<MotionArtwork?> find(MotionArtworkAlbum album) async {
|
||||
if (_clearing) return null;
|
||||
try {
|
||||
final root = await _directory();
|
||||
final key = _key(album);
|
||||
if (_invalid.contains(key)) return null;
|
||||
final file = File('${root.path}/$key.mp4');
|
||||
if (!await file.exists() || await file.length() == 0) return null;
|
||||
double? ratio;
|
||||
var version = 0;
|
||||
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;
|
||||
if (data is Map) {
|
||||
if (data['version'] is int) version = data['version'] as int;
|
||||
if (data['aspectRatio'] is num) {
|
||||
final value = (data['aspectRatio'] as num).toDouble();
|
||||
if (value.isFinite && value > 0) ratio = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Older downloads could report success with corrupt HLS byte ranges.
|
||||
// Check those once per session; retain healthy covers for offline use.
|
||||
if (version != _cacheVersion &&
|
||||
!await _legacyChecks.putIfAbsent(key, () async {
|
||||
try {
|
||||
return await _validate(file.path);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
})) {
|
||||
_invalid.add(key);
|
||||
_log.w('Saved motion artwork failed validation; repair required');
|
||||
return null;
|
||||
}
|
||||
return MotionArtwork(file.uri.toString(), aspectRatio: ratio);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -94,8 +121,11 @@ class MotionArtworkStore {
|
||||
_clearing = true;
|
||||
try {
|
||||
await Future.wait(_pending.values.toList());
|
||||
await Future.wait(_legacyChecks.values.toList());
|
||||
final root = await _directory();
|
||||
if (await root.exists()) await root.delete(recursive: true);
|
||||
_legacyChecks.clear();
|
||||
_invalid.clear();
|
||||
} finally {
|
||||
_clearing = false;
|
||||
}
|
||||
@@ -121,16 +151,19 @@ class MotionArtworkStore {
|
||||
!ratio.isFinite ||
|
||||
ratio <= 0 ||
|
||||
!await temporary.exists() ||
|
||||
await temporary.length() == 0) {
|
||||
await temporary.length() == 0 ||
|
||||
!await _validate(temporary.path)) {
|
||||
return null;
|
||||
}
|
||||
infoTemporary = File('${root.path}/$key.partial.json');
|
||||
await infoTemporary.writeAsString(
|
||||
jsonEncode({'aspectRatio': ratio}),
|
||||
jsonEncode({'version': _cacheVersion, 'aspectRatio': ratio}),
|
||||
flush: true,
|
||||
);
|
||||
final file = await temporary.rename('${root.path}/$key.mp4');
|
||||
await infoTemporary.rename('${root.path}/$key.json');
|
||||
_invalid.remove(key);
|
||||
_legacyChecks.remove(key);
|
||||
return MotionArtwork(file.uri.toString(), aspectRatio: ratio);
|
||||
} catch (error) {
|
||||
_log.w('Could not save optional motion artwork: ${error.runtimeType}');
|
||||
@@ -145,40 +178,87 @@ class MotionArtworkStore {
|
||||
}
|
||||
|
||||
static Future<double?> _downloadVideo(String source, String output) async {
|
||||
final completed = Completer<bool>();
|
||||
final original = await downloadMotionArtworkSource(
|
||||
source,
|
||||
'$output.source.mp4',
|
||||
);
|
||||
try {
|
||||
return await _remuxVideo(original?.path ?? source, output);
|
||||
} finally {
|
||||
if (original != null && await original.exists()) await original.delete();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<double?> _remuxVideo(String source, String output) async {
|
||||
// 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,
|
||||
final success = await _run([
|
||||
'-y',
|
||||
'-protocol_whitelist',
|
||||
'file,http,https,tcp,tls,crypto',
|
||||
'-rw_timeout',
|
||||
'10000000',
|
||||
// HLS byte ranges can share one MP4 URL. Reusing/prefetching HTTP
|
||||
// connections can splice the next range into an unfinished segment.
|
||||
if (Uri.parse(source).path.toLowerCase().endsWith('.m3u8')) ...[
|
||||
'-http_multiple',
|
||||
'0',
|
||||
'-http_persistent',
|
||||
'0',
|
||||
],
|
||||
(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),
|
||||
'-i',
|
||||
source,
|
||||
'-map',
|
||||
'0:v:0',
|
||||
'-an',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-c:v',
|
||||
'copy',
|
||||
'-t',
|
||||
'30',
|
||||
'-fs',
|
||||
'25165824',
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
output,
|
||||
], timeout: const Duration(seconds: 60));
|
||||
if (!success) return null;
|
||||
final dimensions = await FFmpegService.probeImageDimensions(output);
|
||||
return dimensions == null ? null : dimensions.width / dimensions.height;
|
||||
}
|
||||
|
||||
static Future<bool> _validateVideo(String path) => _run([
|
||||
'-v',
|
||||
'error',
|
||||
'-xerror',
|
||||
'-err_detect',
|
||||
'explode',
|
||||
'-i',
|
||||
path,
|
||||
'-map',
|
||||
'0:v:0',
|
||||
'-an',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-f',
|
||||
'null',
|
||||
'-',
|
||||
], timeout: const Duration(seconds: 20));
|
||||
|
||||
static Future<bool> _run(
|
||||
List<String> arguments, {
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final completed = Completer<bool>();
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(arguments, (
|
||||
session,
|
||||
) async {
|
||||
final success = ReturnCode.isSuccess(await session.getReturnCode());
|
||||
if (!completed.isCompleted) completed.complete(success);
|
||||
});
|
||||
return completed.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () async {
|
||||
await FFmpegKit.cancel(session.getSessionId());
|
||||
// Wait for this session to release its file before removing partials.
|
||||
@@ -189,8 +269,5 @@ class MotionArtworkStore {
|
||||
return false;
|
||||
},
|
||||
);
|
||||
if (!success) return null;
|
||||
final dimensions = await FFmpegService.probeImageDimensions(output);
|
||||
return dimensions == null ? null : dimensions.width / dimensions.height;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
|
||||
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/screens/now_playing_screen.dart';
|
||||
@@ -43,6 +45,19 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final mornye = context.isMornye;
|
||||
final compact = mornye && widget.compact;
|
||||
if (mornye && !MediaQuery.disableAnimationsOf(context)) {
|
||||
final artwork = ref
|
||||
.watch(
|
||||
playerMotionArtworkProvider((
|
||||
album: mediaItem.album ?? '',
|
||||
artist: mediaItem.artist ?? '',
|
||||
)),
|
||||
)
|
||||
.value;
|
||||
if (artwork != null) {
|
||||
ref.watch(playerArtworkVideoProvider(artwork.source));
|
||||
}
|
||||
}
|
||||
|
||||
final player = Dismissible(
|
||||
key: ValueKey('mini-player-${mediaItem.id}'),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
/// Samples rendered video and its fade, rather than a static album palette.
|
||||
/// Read back only 48 pixels across, at most three times a second when visible.
|
||||
class MornyeArtworkContrast extends StatefulWidget {
|
||||
const MornyeArtworkContrast({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.targets,
|
||||
required this.onChanged,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final Map<String, GlobalKey> targets;
|
||||
final ValueChanged<Map<String, Color>> onChanged;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<MornyeArtworkContrast> createState() => _MornyeArtworkContrastState();
|
||||
}
|
||||
|
||||
class _MornyeArtworkContrastState extends State<MornyeArtworkContrast> {
|
||||
final _background = GlobalKey();
|
||||
Timer? _timer;
|
||||
bool _sampling = false;
|
||||
Map<String, Color> _colors = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_schedule();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MornyeArtworkContrast oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.enabled != widget.enabled) _schedule();
|
||||
}
|
||||
|
||||
void _schedule() {
|
||||
_timer?.cancel();
|
||||
if (!widget.enabled) {
|
||||
_colors = {};
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _sample());
|
||||
_timer = Timer.periodic(
|
||||
const Duration(milliseconds: 333),
|
||||
(_) => _sample(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sample() async {
|
||||
if (!mounted || !widget.enabled || _sampling) return;
|
||||
final lifecycle = WidgetsBinding.instance.lifecycleState;
|
||||
if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return;
|
||||
final route = ModalRoute.of(context);
|
||||
if (route != null &&
|
||||
(!route.isCurrent || route.animation?.isAnimating == true)) {
|
||||
return;
|
||||
}
|
||||
final boundary = _background.currentContext?.findRenderObject();
|
||||
if (boundary is! RenderRepaintBoundary ||
|
||||
!boundary.hasSize ||
|
||||
boundary.debugNeedsPaint ||
|
||||
boundary.size.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_sampling = true;
|
||||
ui.Image? image;
|
||||
try {
|
||||
final scale = (48 / boundary.size.width).clamp(0.01, 1.0);
|
||||
image = await boundary.toImage(pixelRatio: scale);
|
||||
final pixels = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
if (!mounted || !widget.enabled || pixels == null) return;
|
||||
final origin = boundary.localToGlobal(Offset.zero);
|
||||
final next = <String, Color>{};
|
||||
for (final entry in widget.targets.entries) {
|
||||
final target = entry.value.currentContext?.findRenderObject();
|
||||
if (target is! RenderBox || !target.hasSize || !target.attached) {
|
||||
continue;
|
||||
}
|
||||
final rect = target.localToGlobal(Offset.zero) - origin & target.size;
|
||||
final left = (rect.left * scale).floor().clamp(0, image.width);
|
||||
final right = (rect.right * scale).ceil().clamp(0, image.width);
|
||||
final top = (rect.top * scale).floor().clamp(0, image.height);
|
||||
final bottom = (rect.bottom * scale).ceil().clamp(0, image.height);
|
||||
var luminance = 0.0;
|
||||
var count = 0;
|
||||
for (var y = top; y < bottom; y++) {
|
||||
for (var x = left; x < right; x++) {
|
||||
final index = (y * image.width + x) * 4;
|
||||
luminance += Color.fromARGB(
|
||||
255,
|
||||
pixels.getUint8(index),
|
||||
pixels.getUint8(index + 1),
|
||||
pixels.getUint8(index + 2),
|
||||
).computeLuminance();
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count == 0) continue;
|
||||
// Hysteresis avoids flicker when frames hover around the crossover.
|
||||
final threshold = _colors[entry.key] == Colors.black ? 0.16 : 0.20;
|
||||
next[entry.key] = luminance / count > threshold
|
||||
? Colors.black
|
||||
: Colors.white;
|
||||
}
|
||||
if (next.entries.any((entry) => _colors[entry.key] != entry.value)) {
|
||||
_colors = next;
|
||||
widget.onChanged(next);
|
||||
}
|
||||
} catch (_) {
|
||||
// Unreadable surfaces keep the light controls over the dark fade.
|
||||
} finally {
|
||||
image?.dispose();
|
||||
_sampling = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
RepaintBoundary(key: _background, child: widget.child);
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
class MornyePlayerArtwork extends StatelessWidget {
|
||||
class MornyePlayerArtwork extends ConsumerStatefulWidget {
|
||||
const MornyePlayerArtwork({
|
||||
super.key,
|
||||
required this.mediaItem,
|
||||
this.videoUrl,
|
||||
this.onAspectRatioChanged,
|
||||
this.onError,
|
||||
this.resolvingVideo = false,
|
||||
});
|
||||
|
||||
final MediaItem mediaItem;
|
||||
final String? videoUrl;
|
||||
final ValueChanged<double>? onAspectRatioChanged;
|
||||
final VoidCallback? onError;
|
||||
final bool resolvingVideo;
|
||||
|
||||
@override
|
||||
ConsumerState<MornyePlayerArtwork> createState() =>
|
||||
_MornyePlayerArtworkState();
|
||||
}
|
||||
|
||||
class _MornyePlayerArtworkState extends ConsumerState<MornyePlayerArtwork> {
|
||||
String? _displayedSource;
|
||||
VideoPlayerController? _displayedController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,7 +38,7 @@ class MornyePlayerArtwork extends StatelessWidget {
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
artUri: widget.mediaItem.artUri?.toString(),
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
cacheWidth:
|
||||
(MediaQuery.sizeOf(context).width *
|
||||
@@ -33,15 +47,53 @@ class MornyePlayerArtwork extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (MediaQuery.disableAnimationsOf(context)) return fallback;
|
||||
return videoUrl == null
|
||||
? fallback
|
||||
: MotionHeaderBanner(
|
||||
key: ValueKey(videoUrl),
|
||||
videoUrl: videoUrl!,
|
||||
fallback: fallback,
|
||||
onAspectRatioChanged: onAspectRatioChanged,
|
||||
onError: onError,
|
||||
);
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
_displayedSource = null;
|
||||
_displayedController = null;
|
||||
return fallback;
|
||||
}
|
||||
final videoUrl = widget.videoUrl;
|
||||
// Keep one outgoing decoder alive until the next cover is ready. Rapid
|
||||
// skips must not replace a visible frame with a loading placeholder.
|
||||
if (_displayedSource != null && _displayedSource != videoUrl) {
|
||||
ref.watch(playerArtworkVideoProvider(_displayedSource!));
|
||||
}
|
||||
final prepared = videoUrl == null
|
||||
? null
|
||||
: ref.watch(playerArtworkVideoProvider(videoUrl));
|
||||
if (prepared?.hasError == true) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onError?.call();
|
||||
});
|
||||
}
|
||||
if (prepared?.value case final controller?) {
|
||||
_displayedSource = videoUrl;
|
||||
_displayedController = controller;
|
||||
} else if (!widget.resolvingVideo &&
|
||||
(videoUrl == null || prepared?.hasError == true)) {
|
||||
_displayedSource = null;
|
||||
_displayedController = null;
|
||||
}
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
fallback,
|
||||
if (_displayedController != null)
|
||||
MotionHeaderBanner(
|
||||
videoUrl: _displayedSource!,
|
||||
fallback: const SizedBox.shrink(),
|
||||
controller: _displayedController,
|
||||
fadeDuration: Duration.zero,
|
||||
onAspectRatioChanged: (ratio) {
|
||||
if (_displayedSource == widget.videoUrl) {
|
||||
widget.onAspectRatioChanged?.call(ratio);
|
||||
}
|
||||
},
|
||||
onError: () {
|
||||
if (_displayedSource == widget.videoUrl) widget.onError?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,9 +58,7 @@ class MornyePlayerBackground extends StatelessWidget {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: motion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
|
||||
@@ -12,10 +12,12 @@ class MornyePlayerFavoriteButton extends ConsumerStatefulWidget {
|
||||
super.key,
|
||||
required this.mediaItem,
|
||||
this.compact = false,
|
||||
this.color,
|
||||
});
|
||||
|
||||
final MediaItem mediaItem;
|
||||
final bool compact;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
ConsumerState<MornyePlayerFavoriteButton> createState() =>
|
||||
@@ -66,7 +68,7 @@ class _MornyePlayerFavoriteButtonState
|
||||
tooltip: loved
|
||||
? context.l10n.trackOptionRemoveFromLoved
|
||||
: context.l10n.trackOptionAddToLoved,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
color: widget.color ?? Theme.of(context).colorScheme.onSurface,
|
||||
iconSize: widget.compact ? 20 : 24,
|
||||
onPressed: _saving ? null : _toggle,
|
||||
),
|
||||
|
||||
@@ -36,7 +36,9 @@ final systemVolumeWriterProvider = Provider<Future<void> Function(double)>((
|
||||
});
|
||||
|
||||
class MornyeVolumeControl extends ConsumerStatefulWidget {
|
||||
const MornyeVolumeControl({super.key});
|
||||
const MornyeVolumeControl({super.key, this.foreground = Colors.white});
|
||||
|
||||
final Color foreground;
|
||||
|
||||
@override
|
||||
ConsumerState<MornyeVolumeControl> createState() =>
|
||||
@@ -103,10 +105,10 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
CupertinoIcons.speaker_fill,
|
||||
size: 16,
|
||||
color: Colors.white54,
|
||||
color: widget.foreground.withValues(alpha: 0.54),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
@@ -117,8 +119,8 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
|
||||
opacity: volume == null ? 0.4 : 1,
|
||||
child: MornyePlayerSlider(
|
||||
value: value,
|
||||
activeColor: Colors.white70,
|
||||
inactiveColor: Colors.white12,
|
||||
activeColor: widget.foreground.withValues(alpha: 0.70),
|
||||
inactiveColor: widget.foreground.withValues(alpha: 0.12),
|
||||
onChangeStart: (_) => _dragging = true,
|
||||
onChanged: _setVolume,
|
||||
onChangeEnd: (value) {
|
||||
@@ -131,10 +133,10 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Icon(
|
||||
Icon(
|
||||
CupertinoIcons.speaker_3_fill,
|
||||
size: 16,
|
||||
color: Colors.white54,
|
||||
color: widget.foreground.withValues(alpha: 0.54),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -14,6 +14,8 @@ class MotionHeaderBanner extends StatefulWidget {
|
||||
final Alignment alignment;
|
||||
final ValueChanged<double>? onAspectRatioChanged;
|
||||
final VoidCallback? onError;
|
||||
final VideoPlayerController? controller;
|
||||
final Duration fadeDuration;
|
||||
|
||||
const MotionHeaderBanner({
|
||||
super.key,
|
||||
@@ -23,6 +25,8 @@ class MotionHeaderBanner extends StatefulWidget {
|
||||
this.alignment = Alignment.topCenter,
|
||||
this.onAspectRatioChanged,
|
||||
this.onError,
|
||||
this.controller,
|
||||
this.fadeDuration = const Duration(milliseconds: 400),
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -32,6 +36,7 @@ class MotionHeaderBanner extends StatefulWidget {
|
||||
class _MotionHeaderBannerState extends State<MotionHeaderBanner>
|
||||
with WidgetsBindingObserver {
|
||||
VideoPlayerController? _controller;
|
||||
bool _ownsController = true;
|
||||
bool _ready = false;
|
||||
bool _failed = false;
|
||||
bool _headerVisible = true;
|
||||
@@ -86,7 +91,8 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
|
||||
@override
|
||||
void didUpdateWidget(MotionHeaderBanner oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.videoUrl != widget.videoUrl) {
|
||||
if (oldWidget.videoUrl != widget.videoUrl ||
|
||||
oldWidget.controller != widget.controller) {
|
||||
_disposeController();
|
||||
_ready = false;
|
||||
_failed = false;
|
||||
@@ -95,6 +101,19 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
final prepared = widget.controller;
|
||||
if (prepared != null && prepared.value.isInitialized) {
|
||||
_ownsController = false;
|
||||
_controller = prepared;
|
||||
_ready = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !identical(prepared, _controller)) return;
|
||||
widget.onAspectRatioChanged?.call(prepared.value.aspectRatio);
|
||||
_syncPlayback();
|
||||
});
|
||||
return;
|
||||
}
|
||||
_ownsController = true;
|
||||
final url = widget.videoUrl.trim();
|
||||
if (url.isEmpty) {
|
||||
setState(() => _failed = true);
|
||||
@@ -136,7 +155,11 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
|
||||
void _disposeController() {
|
||||
final controller = _controller;
|
||||
_controller = null;
|
||||
controller?.dispose();
|
||||
if (_ownsController) {
|
||||
controller?.dispose();
|
||||
} else {
|
||||
controller?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -163,7 +186,7 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
|
||||
widget.fallback,
|
||||
AnimatedOpacity(
|
||||
opacity: showVideo ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
duration: widget.fadeDuration,
|
||||
child: showVideo
|
||||
? FittedBox(
|
||||
fit: widget.fit,
|
||||
|
||||
@@ -66,6 +66,10 @@ class _PlaybackSeekSliderState extends State<PlaybackSeekSlider> {
|
||||
child: IgnorePointer(
|
||||
ignoring: !enabled,
|
||||
child: MornyePlayerSlider(
|
||||
activeColor:
|
||||
SliderTheme.of(context).activeTrackColor ?? Colors.white,
|
||||
inactiveColor:
|
||||
SliderTheme.of(context).inactiveTrackColor ?? Colors.white12,
|
||||
value: enabled ? currentMs : 0,
|
||||
max: maxMs,
|
||||
onChangeStart: (_) => _gestureGeneration++,
|
||||
|
||||
@@ -40,8 +40,9 @@ class PlayerArtwork extends StatelessWidget {
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
memCacheWidth: cacheWidth,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: const Duration(milliseconds: 0),
|
||||
useOldImageOnUrlChange: true,
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
@@ -54,6 +55,7 @@ class PlayerArtwork extends StatelessWidget {
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/download_motion_artwork_source.dart';
|
||||
import 'package:spotiflac_android/services/motion_artwork_store.dart';
|
||||
|
||||
void main() {
|
||||
const track = Track(
|
||||
id: 'catalog:track-1',
|
||||
source: 'catalog',
|
||||
albumId: 'catalog:album-1',
|
||||
name: 'Song',
|
||||
albumName: 'Album',
|
||||
artistName: 'Artist',
|
||||
duration: 180,
|
||||
);
|
||||
const url = 'https://example.test/artwork.m3u8';
|
||||
late List<(String, String, String)> calls;
|
||||
late Map<String, dynamic> albumResponse;
|
||||
late Map<String, dynamic> trackResponse;
|
||||
|
||||
Future<Map<String, dynamic>> metadata(
|
||||
String provider,
|
||||
String type,
|
||||
String id,
|
||||
) async {
|
||||
calls.add((provider, type, id));
|
||||
return type == 'album' ? albumResponse : trackResponse;
|
||||
}
|
||||
|
||||
Future<String?> resolve({
|
||||
Track original = track,
|
||||
Track downloaded = track,
|
||||
Map<String, dynamic> result = const {},
|
||||
}) => resolveDownloadMotionArtworkSource(
|
||||
original: original,
|
||||
downloaded: downloaded,
|
||||
result: result,
|
||||
getMetadata: metadata,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
calls = [];
|
||||
albumResponse = {
|
||||
'album_info': {'header_video': url},
|
||||
};
|
||||
trackResponse = {
|
||||
'track': {'album_id': 'album-1'},
|
||||
};
|
||||
});
|
||||
|
||||
test(
|
||||
'search download gets artwork from its own metadata extension',
|
||||
() async {
|
||||
expect(
|
||||
await resolve(downloaded: track.copyWith(source: 'audio-provider')),
|
||||
url,
|
||||
);
|
||||
expect(calls, [('catalog', 'album', 'album-1')]);
|
||||
},
|
||||
);
|
||||
|
||||
test('direct artwork does not fetch metadata', () async {
|
||||
expect(await resolve(original: track.copyWith(headerVideoUrl: url)), url);
|
||||
expect(await resolve(result: {'header_video': url}), url);
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
|
||||
test('invalid direct artwork does not hide valid download artwork', () async {
|
||||
expect(
|
||||
await resolve(
|
||||
original: track.copyWith(headerVideoUrl: 'file:///private/cover.mp4'),
|
||||
result: {'header_video': url},
|
||||
),
|
||||
url,
|
||||
);
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
|
||||
test('missing album ID is hydrated from the same source track', () async {
|
||||
final original = Track.fromJson({...track.toJson(), 'albumId': null});
|
||||
expect(await resolve(original: original), url);
|
||||
expect(calls, [
|
||||
('catalog', 'track', 'track-1'),
|
||||
('catalog', 'album', 'album-1'),
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'missing source never selects the downloader or another extension',
|
||||
() async {
|
||||
final original = Track.fromJson({
|
||||
...track.toJson(),
|
||||
'id': 'track-1',
|
||||
'source': null,
|
||||
});
|
||||
expect(await resolve(original: original), isNull);
|
||||
expect(calls, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('missing artwork never falls back to a different source', () async {
|
||||
albumResponse = {'album_info': <String, dynamic>{}};
|
||||
expect(await resolve(), isNull);
|
||||
expect(calls, [('catalog', 'album', 'album-1')]);
|
||||
});
|
||||
|
||||
test('URL-handler album payload is accepted', () async {
|
||||
albumResponse = {
|
||||
'album': {'header_video': url},
|
||||
};
|
||||
expect(await resolve(), url);
|
||||
});
|
||||
|
||||
test('source-only download is saved and reused offline', () async {
|
||||
final root = await Directory.systemTemp.createTemp('download-motion-');
|
||||
addTearDown(() => root.delete(recursive: true));
|
||||
var downloads = 0;
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
validate: (_) async => true,
|
||||
download: (source, output) async {
|
||||
expect(source, url);
|
||||
downloads++;
|
||||
await File(output).writeAsBytes([1, 2, 3]);
|
||||
return 0.75;
|
||||
},
|
||||
);
|
||||
const album = (album: 'Album', artist: 'Artist');
|
||||
final saved = await store.save(album, resolveSource: resolve);
|
||||
expect(saved, isNotNull);
|
||||
expect(await File.fromUri(Uri.parse(saved!.source)).exists(), isTrue);
|
||||
calls.clear();
|
||||
expect((await store.find(album))?.source, saved.source);
|
||||
expect(
|
||||
(await store.save(album, resolveSource: resolve))?.source,
|
||||
saved.source,
|
||||
);
|
||||
expect(calls, isEmpty);
|
||||
expect(downloads, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('foreground follows changing frames separately for each region', (
|
||||
tester,
|
||||
) async {
|
||||
final header = GlobalKey();
|
||||
final controls = GlobalKey();
|
||||
Map<String, Color> colors = {};
|
||||
var changes = 0;
|
||||
Widget app(Color top, Color bottom) => MaterialApp(
|
||||
home: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MornyeArtworkContrast(
|
||||
enabled: true,
|
||||
targets: {'header': header, 'controls': controls},
|
||||
onChanged: (value) {
|
||||
colors = value;
|
||||
changes++;
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ColoredBox(color: top, child: const SizedBox.expand()),
|
||||
),
|
||||
Expanded(
|
||||
child: ColoredBox(
|
||||
color: bottom,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 20,
|
||||
left: 20,
|
||||
width: 100,
|
||||
height: 30,
|
||||
child: SizedBox(key: header),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
width: 100,
|
||||
height: 30,
|
||||
child: SizedBox(key: controls),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Future<void> sample() async {
|
||||
await tester.pump(const Duration(milliseconds: 334));
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 30)),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(app(Colors.white, Colors.black));
|
||||
await sample();
|
||||
expect(colors, {'header': Colors.black, 'controls': Colors.white});
|
||||
await tester.pumpWidget(app(Colors.black, Colors.white));
|
||||
await sample();
|
||||
expect(colors, {'header': Colors.white, 'controls': Colors.black});
|
||||
final previousChanges = changes;
|
||||
await sample();
|
||||
expect(changes, previousChanges);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
await tester.pumpWidget(app(Colors.white, Colors.black));
|
||||
await sample();
|
||||
expect(changes, previousChanges);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
await sample();
|
||||
expect(colors, {'header': Colors.black, 'controls': Colors.white});
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/motion_artwork_download.dart';
|
||||
|
||||
void main() {
|
||||
late HttpServer server;
|
||||
late Directory root;
|
||||
late Uri base;
|
||||
late Map<String, String> playlists;
|
||||
late List<String> requests;
|
||||
late List<int> video;
|
||||
late int status;
|
||||
late int? declaredLength;
|
||||
late String media;
|
||||
const validMedia = '''
|
||||
#EXTM3U
|
||||
#EXT-X-MAP:URI="cover.mp4",BYTERANGE="4@0"
|
||||
#EXTINF:1,
|
||||
#EXT-X-BYTERANGE:4@4
|
||||
cover.mp4
|
||||
#EXTINF:1,
|
||||
#EXT-X-BYTERANGE:4
|
||||
cover.mp4
|
||||
#EXT-X-ENDLIST
|
||||
''';
|
||||
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('motion-download-test-');
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
base = Uri.parse('http://127.0.0.1:${server.port}/');
|
||||
requests = [];
|
||||
video = List.generate(12, (index) => index);
|
||||
status = HttpStatus.ok;
|
||||
declaredLength = null;
|
||||
media = validMedia;
|
||||
playlists = {
|
||||
'/master.m3u8': '''
|
||||
#EXTM3U
|
||||
#EXT-X-I-FRAME-STREAM-INF:CODECS="avc1.64001f",URI="preview.m3u8"
|
||||
#EXT-X-STREAM-INF:CODECS="hvc1.2.4.L120",RESOLUTION=664x886
|
||||
hevc.m3u8
|
||||
#EXT-X-STREAM-INF:CODECS="avc1.64001f",RESOLUTION=664x886
|
||||
video/media.m3u8
|
||||
''',
|
||||
};
|
||||
server.listen((request) async {
|
||||
requests.add(request.uri.path);
|
||||
expect(request.headers.value(HttpHeaders.rangeHeader), isNull);
|
||||
if (request.uri.path == '/redirect.m3u8') {
|
||||
await request.response.redirect(base.resolve('video/media.m3u8'));
|
||||
return;
|
||||
}
|
||||
final text = request.uri.path == '/video/media.m3u8'
|
||||
? media
|
||||
: playlists[request.uri.path];
|
||||
if (text != null) {
|
||||
request.response.write(text);
|
||||
} else if (request.uri.path == '/video/cover.mp4') {
|
||||
request.response.statusCode = status;
|
||||
if (declaredLength != null) {
|
||||
request.response.contentLength = declaredLength!;
|
||||
await request.response.flush();
|
||||
final socket = await request.response.detachSocket();
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
request.response.add(video);
|
||||
} else {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
}
|
||||
await request.response.close();
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.close(force: true);
|
||||
await root.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<File?> download([String path = 'master.m3u8']) =>
|
||||
downloadMotionArtworkSource(
|
||||
base.resolve(path).toString(),
|
||||
'${root.path}/source.mp4',
|
||||
);
|
||||
|
||||
test(
|
||||
'fetches the complete H.264 resource once without range requests',
|
||||
() async {
|
||||
final file = await download();
|
||||
expect(await file!.readAsBytes(), video);
|
||||
expect(requests, [
|
||||
'/master.m3u8',
|
||||
'/video/media.m3u8',
|
||||
'/video/cover.mp4',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('resolves media URLs relative to the redirected playlist', () async {
|
||||
final file = await download('redirect.m3u8');
|
||||
expect(await file!.readAsBytes(), video);
|
||||
expect(requests.last, '/video/cover.mp4');
|
||||
});
|
||||
|
||||
test('leaves other sources to the regular remuxer', () async {
|
||||
expect(await download('cover.mp4'), isNull);
|
||||
expect(requests, isEmpty);
|
||||
expect(root.listSync(), isEmpty);
|
||||
});
|
||||
|
||||
for (final unsupported in {
|
||||
'separate segments': validMedia.replaceFirst(
|
||||
'#EXT-X-BYTERANGE:4\ncover.mp4',
|
||||
'#EXT-X-BYTERANGE:4\nother.mp4',
|
||||
),
|
||||
'missing bytes': validMedia.replaceFirst('4@4', '4@5'),
|
||||
'nonzero initial offset': validMedia.replaceFirst('4@0', '4@1'),
|
||||
'encrypted segments': validMedia.replaceFirst(
|
||||
'#EXTM3U',
|
||||
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="key"',
|
||||
),
|
||||
'live playlist': validMedia.replaceFirst('#EXT-X-ENDLIST', ''),
|
||||
'discontinuity': validMedia.replaceFirst(
|
||||
'#EXTINF:1,',
|
||||
'#EXT-X-DISCONTINUITY\n#EXTINF:1,',
|
||||
),
|
||||
'non-HTTP video': validMedia.replaceAll('cover.mp4', 'file:///cover.mp4'),
|
||||
}.entries) {
|
||||
test('does not flatten ${unsupported.key}', () async {
|
||||
media = unsupported.value;
|
||||
expect(await download('video/media.m3u8'), isNull);
|
||||
expect(requests, ['/video/media.m3u8']);
|
||||
expect(root.listSync(), isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects a partial HTTP response', () async {
|
||||
status = HttpStatus.partialContent;
|
||||
await expectLater(download(), throwsA(isA<HttpException>()));
|
||||
expect(root.listSync(), isEmpty);
|
||||
});
|
||||
|
||||
test('rejects missing segment bytes and removes the partial file', () async {
|
||||
video = [1, 2, 3];
|
||||
await expectLater(download(), throwsFormatException);
|
||||
expect(root.listSync(), isEmpty);
|
||||
});
|
||||
|
||||
test('bounds video size before accepting a large response', () async {
|
||||
declaredLength = 25 << 20;
|
||||
await expectLater(download(), throwsFormatException);
|
||||
expect(root.listSync(), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/motion_artwork_store.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const album = (album: 'An Album', artist: 'An Artist');
|
||||
late Directory root;
|
||||
setUp(
|
||||
@@ -16,6 +19,151 @@ void main() {
|
||||
);
|
||||
tearDown(() async => root.delete(recursive: true));
|
||||
|
||||
Future<File> seedLegacyVideo() async {
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
validate: (_) async => true,
|
||||
download: (_, output) async {
|
||||
await File(output).writeAsBytes([1]);
|
||||
return 0.75;
|
||||
},
|
||||
);
|
||||
final saved = await store.save(
|
||||
album,
|
||||
resolveSource: () async => 'https://example.com/cover.m3u8',
|
||||
);
|
||||
final file = File.fromUri(Uri.parse(saved!.source));
|
||||
final info = root.listSync().whereType<File>().singleWhere(
|
||||
(file) => file.path.endsWith('.json'),
|
||||
);
|
||||
await info.writeAsString('{"aspectRatio":0.75}');
|
||||
return file;
|
||||
}
|
||||
|
||||
test('successful transfers with broken video data are not cached', () async {
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
download: (_, output) async {
|
||||
await File(output).writeAsBytes([1]);
|
||||
return 0.75;
|
||||
},
|
||||
validate: (_) async => false,
|
||||
);
|
||||
expect(
|
||||
await store.save(
|
||||
album,
|
||||
resolveSource: () async => 'https://example.com/cover.m3u8',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(root.listSync(), isEmpty);
|
||||
});
|
||||
|
||||
test('corrupt legacy video is replaced only after a valid repair', () async {
|
||||
final legacy = await seedLegacyVideo();
|
||||
var failDownload = true;
|
||||
var validations = 0;
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
validate: (path) async {
|
||||
validations++;
|
||||
return (await File(path).readAsBytes()).single == 2;
|
||||
},
|
||||
download: (_, output) async {
|
||||
if (failDownload) throw const SocketException('offline');
|
||||
await File(output).writeAsBytes([2]);
|
||||
return 0.75;
|
||||
},
|
||||
);
|
||||
expect(await store.find(album), isNull);
|
||||
expect(await store.find(album), isNull);
|
||||
expect(validations, 1);
|
||||
Future<String?> resolve() async => 'https://example.com/cover.m3u8';
|
||||
expect(await store.save(album, resolveSource: resolve), isNull);
|
||||
expect(await legacy.readAsBytes(), [1]);
|
||||
failDownload = false;
|
||||
final repaired = await store.save(album, resolveSource: resolve);
|
||||
expect(repaired?.source, legacy.uri.toString());
|
||||
expect(await legacy.readAsBytes(), [2]);
|
||||
expect((await store.find(album))?.source, repaired?.source);
|
||||
expect(validations, 2);
|
||||
expect(
|
||||
root.listSync().where((file) => file.path.contains('partial')),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'healthy legacy video stays available offline after validation',
|
||||
() async {
|
||||
final legacy = await seedLegacyVideo();
|
||||
var validations = 0;
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
validate: (_) async {
|
||||
validations++;
|
||||
return true;
|
||||
},
|
||||
download: (_, _) async => throw StateError('offline'),
|
||||
);
|
||||
final results = await Future.wait([store.find(album), store.find(album)]);
|
||||
expect(
|
||||
results.map((art) => art?.source),
|
||||
everyElement(legacy.uri.toString()),
|
||||
);
|
||||
expect(validations, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('a failed legacy check does not prevent clearing artwork', () async {
|
||||
await seedLegacyVideo();
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
validate: (_) async => throw StateError('decoder unavailable'),
|
||||
);
|
||||
expect(await store.find(album), isNull);
|
||||
await store.clear();
|
||||
expect(await root.exists(), isFalse);
|
||||
await root.create();
|
||||
});
|
||||
|
||||
for (final hasLegacyCopy in [true, false]) {
|
||||
test(
|
||||
'player never searches or downloads missing artwork (legacy=$hasLegacyCopy)',
|
||||
() async {
|
||||
final legacy = hasLegacyCopy ? await seedLegacyVideo() : null;
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
validate: (path) async =>
|
||||
(await File(path).readAsBytes()).single == 2,
|
||||
download: (_, _) async => throw StateError('must not download'),
|
||||
);
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
fail('Playback must not call an extension: ${call.method}');
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
final container = ProviderContainer(
|
||||
overrides: [motionArtworkStoreProvider.overrideWithValue(store)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final initial = await container.read(
|
||||
playerMotionArtworkProvider(album).future,
|
||||
);
|
||||
expect(initial, isNull);
|
||||
expect(container.exists(extensionProvider), isFalse);
|
||||
expect(container.exists(settingsProvider), isFalse);
|
||||
if (legacy != null) {
|
||||
expect(await legacy.readAsBytes(), [1]);
|
||||
} else {
|
||||
expect(await store.find(album), isNull);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'saved video survives restart and app directory relocation offline',
|
||||
() async {
|
||||
@@ -23,6 +171,7 @@ void main() {
|
||||
var location = Directory('${root.path}/old-container');
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => location,
|
||||
validate: (_) async => true,
|
||||
download: (_, output) async {
|
||||
downloads++;
|
||||
await File(output).writeAsBytes([1, 2, 3, 4]);
|
||||
@@ -43,6 +192,7 @@ void main() {
|
||||
location = await location.rename('${root.path}/new-container');
|
||||
final restarted = MotionArtworkStore(
|
||||
directory: () async => location,
|
||||
validate: (_) async => throw StateError('already validated'),
|
||||
download: (_, _) async => throw StateError('offline'),
|
||||
);
|
||||
final cached = await restarted.save((
|
||||
@@ -70,6 +220,7 @@ void main() {
|
||||
var lookups = 0;
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
validate: (_) async => true,
|
||||
download: (_, output) async {
|
||||
downloads++;
|
||||
await gate.future;
|
||||
@@ -101,6 +252,7 @@ void main() {
|
||||
var fail = true;
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => root,
|
||||
validate: (_) async => true,
|
||||
download: (_, output) async {
|
||||
await File(output).writeAsBytes([1]);
|
||||
if (fail) throw const SocketException('offline');
|
||||
@@ -150,6 +302,7 @@ void main() {
|
||||
final started = Completer<void>();
|
||||
final store = MotionArtworkStore(
|
||||
directory: () async => Directory('${root.path}/motion'),
|
||||
validate: (_) async => true,
|
||||
download: (_, output) async {
|
||||
started.complete();
|
||||
await gate.future;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_artwork.dart';
|
||||
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
||||
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
|
||||
|
||||
@@ -9,6 +15,9 @@ class _VideoPlatform extends VideoPlayerPlatform {
|
||||
bool looping = false;
|
||||
double volume = 1;
|
||||
DataSource? source;
|
||||
int creations = 0;
|
||||
int disposals = 0;
|
||||
Map<int, StreamController<VideoEvent>>? events;
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
@@ -16,17 +25,21 @@ class _VideoPlatform extends VideoPlayerPlatform {
|
||||
@override
|
||||
Future<int?> createWithOptions(VideoCreationOptions options) async {
|
||||
source = options.dataSource;
|
||||
return 1;
|
||||
final id = ++creations;
|
||||
events?[id] = StreamController<VideoEvent>();
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<VideoEvent> videoEventsFor(int playerId) => Stream.value(
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.initialized,
|
||||
duration: const Duration(seconds: 30),
|
||||
size: const Size(320, 180),
|
||||
),
|
||||
);
|
||||
Stream<VideoEvent> videoEventsFor(int playerId) =>
|
||||
events?[playerId]?.stream ??
|
||||
Stream.value(
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.initialized,
|
||||
duration: const Duration(seconds: 30),
|
||||
size: const Size(320, 180),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> play(int playerId) async {
|
||||
@@ -58,10 +71,126 @@ class _VideoPlatform extends VideoPlayerPlatform {
|
||||
Widget buildViewWithOptions(VideoViewOptions options) => const SizedBox();
|
||||
|
||||
@override
|
||||
Future<void> dispose(int playerId) async {}
|
||||
Future<void> dispose(int playerId) async {
|
||||
disposals++;
|
||||
await events?[playerId]?.close();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'prepared player video opens without a new decoder or cover fade',
|
||||
(tester) async {
|
||||
final previous = VideoPlayerPlatform.instance;
|
||||
final platform = _VideoPlatform();
|
||||
VideoPlayerPlatform.instance = platform;
|
||||
addTearDown(() => VideoPlayerPlatform.instance = previous);
|
||||
final show = ValueNotifier(false);
|
||||
addTearDown(show.dispose);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
ref.watch(playerArtworkVideoProvider('file:///cover.mp4'));
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: show,
|
||||
builder: (_, visible, _) => visible
|
||||
? const MornyePlayerArtwork(
|
||||
mediaItem: MediaItem(id: 'song', title: 'Song'),
|
||||
videoUrl: 'file:///cover.mp4',
|
||||
)
|
||||
: const SizedBox(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(platform.creations, 1);
|
||||
expect(platform.playing, isFalse);
|
||||
show.value = true;
|
||||
await tester.pump();
|
||||
expect(platform.creations, 1);
|
||||
expect(platform.playing, isTrue);
|
||||
final banner = tester.widget<MotionHeaderBanner>(
|
||||
find.byType(MotionHeaderBanner),
|
||||
);
|
||||
expect(banner.controller!.value.isInitialized, isTrue);
|
||||
expect(banner.fadeDuration, Duration.zero);
|
||||
show.value = false;
|
||||
await tester.pump();
|
||||
expect(platform.playing, isFalse);
|
||||
expect(platform.disposals, 0);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump();
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
expect(platform.disposals, 1);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('changing tracks retains video until the new frame is ready', (
|
||||
tester,
|
||||
) async {
|
||||
final previous = VideoPlayerPlatform.instance;
|
||||
final platform = _VideoPlatform()..events = {};
|
||||
VideoPlayerPlatform.instance = platform;
|
||||
addTearDown(() => VideoPlayerPlatform.instance = previous);
|
||||
final source = ValueNotifier('file:///first.mp4');
|
||||
addTearDown(source.dispose);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: ValueListenableBuilder(
|
||||
valueListenable: source,
|
||||
builder: (_, url, _) => MornyePlayerArtwork(
|
||||
mediaItem: MediaItem(id: url, title: 'Song'),
|
||||
videoUrl: url,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
void ready(int id) => platform.events![id]!.add(
|
||||
VideoEvent(
|
||||
eventType: VideoEventType.initialized,
|
||||
duration: const Duration(seconds: 10),
|
||||
size: const Size(300, 400),
|
||||
),
|
||||
);
|
||||
ready(1);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester
|
||||
.widget<MotionHeaderBanner>(find.byType(MotionHeaderBanner))
|
||||
.videoUrl,
|
||||
source.value,
|
||||
);
|
||||
source.value = 'file:///second.mp4';
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester
|
||||
.widget<MotionHeaderBanner>(find.byType(MotionHeaderBanner))
|
||||
.videoUrl,
|
||||
'file:///first.mp4',
|
||||
);
|
||||
expect(platform.disposals, 0);
|
||||
ready(2);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester
|
||||
.widget<MotionHeaderBanner>(find.byType(MotionHeaderBanner))
|
||||
.videoUrl,
|
||||
source.value,
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'offline cover uses a silent looping file and reports its ratio',
|
||||
(tester) async {
|
||||
|
||||
@@ -16,6 +16,9 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
|
||||
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
|
||||
import 'package:spotiflac_android/services/motion_artwork_store.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:spotiflac_android/screens/now_playing_screen.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
|
||||
@@ -89,6 +92,7 @@ void main() {
|
||||
PlaybackState? playback,
|
||||
Stream<PlaybackState>? playbackEvents,
|
||||
Widget Function(Widget)? wrapPlayer,
|
||||
MotionArtwork? motionArtwork,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -99,7 +103,12 @@ void main() {
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentMediaItemProvider.overrideWith((ref) => mediaItems.stream),
|
||||
playerMotionArtworkProvider.overrideWith((ref, album) async => null),
|
||||
playerMotionArtworkProvider.overrideWith(
|
||||
(ref, album) async => motionArtwork,
|
||||
),
|
||||
playerArtworkVideoProvider.overrideWith(
|
||||
(ref, source) => Completer<VideoPlayerController>().future,
|
||||
),
|
||||
playerCollectionTrackProvider.overrideWith(
|
||||
(ref, item) async => Track(
|
||||
id: item.id,
|
||||
@@ -137,6 +146,43 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'portrait motion cover keeps the raised square-cover control positions',
|
||||
(tester) async {
|
||||
Future<(double, double)> positions(MotionArtwork? artwork) async {
|
||||
await pumpNowPlaying(
|
||||
tester,
|
||||
theme: MornyeTheme.build(Brightness.dark),
|
||||
size: const Size(393, 852),
|
||||
motionArtwork: artwork,
|
||||
);
|
||||
mediaItems.add(item('first'));
|
||||
await tester.pumpAndSettle();
|
||||
final title = tester.getTopLeft(find.text('First').hitTestable()).dy;
|
||||
final transport = tester
|
||||
.getCenter(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is MornyePlaybackButton &&
|
||||
widget.icon == CupertinoIcons.play_fill,
|
||||
),
|
||||
)
|
||||
.dy;
|
||||
expect(title, lessThan(852 * 0.64));
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
return (title, transport);
|
||||
}
|
||||
|
||||
final square = await positions(null);
|
||||
final portrait = await positions(
|
||||
const MotionArtwork('file:///cover.mp4', aspectRatio: 0.75),
|
||||
);
|
||||
expect(portrait.$1, closeTo(square.$1, 1));
|
||||
expect(portrait.$2, closeTo(square.$2, 1));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('opening Mornye lyrics centers the current wrapped line', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/widgets/player_artwork.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'track changes retain the decoded cover while the next image loads',
|
||||
(tester) async {
|
||||
final cache = PaintingBinding.instance.imageCache;
|
||||
addTearDown(() {
|
||||
cache.clear();
|
||||
cache.clearLiveImages();
|
||||
});
|
||||
Future<ui.Image> pixel(Color color) async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
Canvas(recorder).drawColor(color, BlendMode.src);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(1, 1);
|
||||
picture.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
final first = await tester.runAsync(() => pixel(Colors.red));
|
||||
final second = await tester.runAsync(() => pixel(Colors.blue));
|
||||
final pending = Completer<ImageInfo>();
|
||||
cache.putIfAbsent(
|
||||
FileImage(File('/cover-first.png')),
|
||||
() => OneFrameImageStreamCompleter(
|
||||
Future.value(ImageInfo(image: first!)),
|
||||
),
|
||||
);
|
||||
cache.putIfAbsent(
|
||||
FileImage(File('/cover-second.png')),
|
||||
() => OneFrameImageStreamCompleter(pending.future),
|
||||
);
|
||||
Widget app(String path) => MaterialApp(
|
||||
home: PlayerArtwork(
|
||||
artUri: path,
|
||||
colorScheme: const ColorScheme.dark(),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(app('/cover-first.png'));
|
||||
await tester.pump();
|
||||
final displayed = tester.widget<RawImage>(find.byType(RawImage)).image;
|
||||
expect(displayed, isNotNull);
|
||||
await tester.pumpWidget(app('/cover-second.png'));
|
||||
expect(
|
||||
tester.widget<RawImage>(find.byType(RawImage)).image,
|
||||
same(displayed),
|
||||
);
|
||||
expect(find.byIcon(Icons.music_note), findsNothing);
|
||||
pending.complete(ImageInfo(image: second!));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<RawImage>(find.byType(RawImage)).image,
|
||||
isNot(same(displayed)),
|
||||
);
|
||||
expect(find.byIcon(Icons.music_note), findsNothing);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
|
||||
|
||||
void main() {
|
||||
const album = (album: 'An Album', artist: 'An Artist');
|
||||
|
||||
test(
|
||||
'motion lookup checks album and artist before loading metadata',
|
||||
() async {
|
||||
final loaded = <String>[];
|
||||
final result = await findPlayerMotionArtwork(
|
||||
album: album,
|
||||
providerIds: ['provider-a'],
|
||||
search: (_, _) async => [
|
||||
{
|
||||
'id': 'wrong-artist',
|
||||
'name': 'An Album',
|
||||
'artists': 'Not An Artist',
|
||||
},
|
||||
{
|
||||
'id': 'wrong-album',
|
||||
'name': 'Another Album',
|
||||
'artists': 'An Artist',
|
||||
},
|
||||
{'id': 'match', 'name': 'AN ALBUM', 'artists': 'An Artist'},
|
||||
],
|
||||
loadAlbum: (_, id) async {
|
||||
loaded.add(id);
|
||||
return {
|
||||
'album_info': {'header_video': 'https://example.com/cover.m3u8'},
|
||||
};
|
||||
},
|
||||
);
|
||||
expect(loaded, ['match']);
|
||||
expect(result, 'https://example.com/cover.m3u8');
|
||||
},
|
||||
);
|
||||
|
||||
test('motion lookup falls back to another provider after an error', () async {
|
||||
final result = await findPlayerMotionArtwork(
|
||||
album: album,
|
||||
providerIds: ['provider-a', 'provider-b'],
|
||||
search: (provider, _) async {
|
||||
if (provider == 'provider-a') throw StateError('offline');
|
||||
return [
|
||||
{
|
||||
'name': 'An Album',
|
||||
'artists': 'An Artist',
|
||||
'header_video': 'https://example.com/motion.m3u8',
|
||||
},
|
||||
];
|
||||
},
|
||||
loadAlbum: (_, _) async =>
|
||||
throw StateError('Direct artwork needs no lookup'),
|
||||
);
|
||||
expect(result, 'https://example.com/motion.m3u8');
|
||||
});
|
||||
|
||||
test('unavailable or unsupported motion keeps the static cover', () async {
|
||||
final result = await findPlayerMotionArtwork(
|
||||
album: album,
|
||||
providerIds: ['provider-a'],
|
||||
search: (_, _) async => [
|
||||
{
|
||||
'name': 'An Album',
|
||||
'artists': 'An Artist',
|
||||
'header_video': 'file:///invalid.m3u8',
|
||||
},
|
||||
],
|
||||
loadAlbum: (_, _) async => {},
|
||||
);
|
||||
expect(result, isNull);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user