mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
fix(player): preserve offline motion artwork and smooth cover changes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user