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
@@ -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;
}
}