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

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