mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
chore: rebuild dev history without streaming-era commits
This commit is contained in:
1 parent
c89600591c
commit
ab26d84632
111 files changed
+18282
-3459
No files matched your search
@@ -106,6 +106,8 @@ class CsvImportService {
|
||||
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||
albumArtist: trackData['album_artist'] as String?,
|
||||
artistId: trackData['artist_id']?.toString(),
|
||||
albumId: trackData['album_id']?.toString(),
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||
|
||||
@@ -10,6 +10,7 @@ class DownloadRequestPayload {
|
||||
final String outputDir;
|
||||
final String filenameFormat;
|
||||
final String quality;
|
||||
final bool embedMetadata;
|
||||
final bool embedLyrics;
|
||||
final bool embedMaxQualityCover;
|
||||
final int trackNumber;
|
||||
@@ -47,6 +48,7 @@ class DownloadRequestPayload {
|
||||
required this.outputDir,
|
||||
required this.filenameFormat,
|
||||
this.quality = 'LOSSLESS',
|
||||
this.embedMetadata = true,
|
||||
this.embedLyrics = true,
|
||||
this.embedMaxQualityCover = true,
|
||||
this.trackNumber = 1,
|
||||
@@ -86,6 +88,7 @@ class DownloadRequestPayload {
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'embed_metadata': embedMetadata,
|
||||
'embed_lyrics': embedLyrics,
|
||||
'embed_max_quality_cover': embedMaxQualityCover,
|
||||
'track_number': trackNumber,
|
||||
@@ -129,6 +132,7 @@ class DownloadRequestPayload {
|
||||
outputDir: outputDir,
|
||||
filenameFormat: filenameFormat,
|
||||
quality: quality,
|
||||
embedMetadata: embedMetadata,
|
||||
embedLyrics: embedLyrics,
|
||||
embedMaxQualityCover: embedMaxQualityCover,
|
||||
trackNumber: trackNumber,
|
||||
|
||||
@@ -25,6 +25,7 @@ class DownloadedEmbeddedCoverResolver {
|
||||
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
|
||||
static final Set<String> _pendingExtract = <String>{};
|
||||
static final Set<String> _pendingRefresh = <String>{};
|
||||
static final Set<String> _pendingPreviewValidation = <String>{};
|
||||
static final Set<String> _failedExtract = <String>{};
|
||||
|
||||
static String cleanFilePath(String? filePath) {
|
||||
@@ -66,12 +67,9 @@ class DownloadedEmbeddedCoverResolver {
|
||||
|
||||
final cached = _cache[cleanPath];
|
||||
if (cached != null) {
|
||||
if (File(cached.previewPath).existsSync()) {
|
||||
_touch(cleanPath, cached);
|
||||
return cached.previewPath;
|
||||
}
|
||||
_cache.remove(cleanPath);
|
||||
_cleanupTempCoverPathSync(cached.previewPath);
|
||||
_touch(cleanPath, cached);
|
||||
_validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged);
|
||||
return cached.previewPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -106,6 +104,7 @@ class DownloadedEmbeddedCoverResolver {
|
||||
final cached = _cache.remove(cleanPath);
|
||||
_pendingExtract.remove(cleanPath);
|
||||
_pendingRefresh.remove(cleanPath);
|
||||
_pendingPreviewValidation.remove(cleanPath);
|
||||
_failedExtract.remove(cleanPath);
|
||||
if (cached != null) {
|
||||
_cleanupTempCoverPathSync(cached.previewPath);
|
||||
@@ -144,10 +143,36 @@ class DownloadedEmbeddedCoverResolver {
|
||||
}
|
||||
_pendingExtract.remove(oldestKey);
|
||||
_pendingRefresh.remove(oldestKey);
|
||||
_pendingPreviewValidation.remove(oldestKey);
|
||||
_failedExtract.remove(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
static void _validateCachedPreviewAsync(
|
||||
String cleanPath,
|
||||
_EmbeddedCoverCacheEntry entry, {
|
||||
VoidCallback? onChanged,
|
||||
}) {
|
||||
if (_pendingPreviewValidation.contains(cleanPath)) return;
|
||||
_pendingPreviewValidation.add(cleanPath);
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
final exists = await fileExists(entry.previewPath);
|
||||
if (!exists) {
|
||||
final latest = _cache[cleanPath];
|
||||
if (latest != null && latest.previewPath == entry.previewPath) {
|
||||
_cache.remove(cleanPath);
|
||||
_failedExtract.remove(cleanPath);
|
||||
onChanged?.call();
|
||||
}
|
||||
_cleanupTempCoverPathSync(entry.previewPath);
|
||||
}
|
||||
} finally {
|
||||
_pendingPreviewValidation.remove(cleanPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void _ensureCover(
|
||||
String cleanPath, {
|
||||
bool forceRefresh = false,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -11,7 +13,20 @@ final _log = AppLogger('FFmpeg');
|
||||
|
||||
class FFmpegService {
|
||||
static const int _commandLogPreviewLength = 300;
|
||||
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
|
||||
static const Duration _liveTunnelStartupPollInterval = Duration(
|
||||
milliseconds: 200,
|
||||
);
|
||||
static const Duration _liveTunnelStabilizationDelay = Duration(
|
||||
milliseconds: 900,
|
||||
);
|
||||
static int _tempEmbedCounter = 0;
|
||||
static FFmpegSession? _activeLiveDecryptSession;
|
||||
static String? _activeLiveDecryptUrl;
|
||||
static String? _activeLiveTempInputPath;
|
||||
static String? _activeNativeDashManifestPath;
|
||||
static String? _activeNativeDashManifestUrl;
|
||||
static final Set<String> _preparedNativeDashManifestPaths = <String>{};
|
||||
|
||||
static String _buildOutputPath(String inputPath, String extension) {
|
||||
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
|
||||
@@ -305,6 +320,433 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool isActiveLiveDecryptedUrl(String url) {
|
||||
final active = _activeLiveDecryptUrl;
|
||||
if (active == null || active.isEmpty) return false;
|
||||
return active == url.trim();
|
||||
}
|
||||
|
||||
static bool isActiveNativeDashManifestUrl(String url) {
|
||||
final activeUrl = _activeNativeDashManifestUrl;
|
||||
if (activeUrl == null || activeUrl.isEmpty) return false;
|
||||
|
||||
final normalized = url.trim();
|
||||
if (activeUrl == normalized) return true;
|
||||
|
||||
try {
|
||||
final activePath = Uri.parse(activeUrl).toFilePath();
|
||||
final incomingPath = Uri.parse(normalized).toFilePath();
|
||||
return activePath == incomingPath;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> prepareTidalDashManifestForNativePlayback({
|
||||
required String manifestPayload,
|
||||
bool registerAsActive = true,
|
||||
}) async {
|
||||
final rawPayload = manifestPayload.trim();
|
||||
if (rawPayload.isEmpty) return null;
|
||||
|
||||
final payload = rawPayload.startsWith('MANIFEST:')
|
||||
? rawPayload.substring('MANIFEST:'.length)
|
||||
: rawPayload;
|
||||
|
||||
final manifestPath = await _writeTempManifestFile(payload);
|
||||
if (manifestPath == null) {
|
||||
_log.e('Failed to prepare Tidal DASH manifest for native playback');
|
||||
return null;
|
||||
}
|
||||
|
||||
final manifestUrl = Uri.file(manifestPath).toString();
|
||||
_preparedNativeDashManifestPaths.add(manifestPath);
|
||||
if (registerAsActive) {
|
||||
await activatePreparedNativeDashManifest(manifestUrl);
|
||||
}
|
||||
return manifestUrl;
|
||||
}
|
||||
|
||||
static Future<void> activatePreparedNativeDashManifest(String url) async {
|
||||
final normalized = url.trim();
|
||||
if (normalized.isEmpty) return;
|
||||
|
||||
final manifestPath = _nativeDashManifestPathFromUrl(normalized);
|
||||
if (manifestPath == null ||
|
||||
!_preparedNativeDashManifestPaths.contains(manifestPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final previousPath = _activeNativeDashManifestPath;
|
||||
_activeNativeDashManifestPath = manifestPath;
|
||||
_activeNativeDashManifestUrl = Uri.file(manifestPath).toString();
|
||||
|
||||
if (previousPath != null &&
|
||||
previousPath.isNotEmpty &&
|
||||
previousPath != manifestPath) {
|
||||
_preparedNativeDashManifestPaths.remove(previousPath);
|
||||
await _deleteNativeDashManifestFile(previousPath);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> stopNativeDashManifestPlayback() async {
|
||||
final manifestPath = _activeNativeDashManifestPath;
|
||||
_activeNativeDashManifestPath = null;
|
||||
_activeNativeDashManifestUrl = null;
|
||||
|
||||
if (manifestPath == null || manifestPath.isEmpty) return;
|
||||
_preparedNativeDashManifestPaths.remove(manifestPath);
|
||||
await _deleteNativeDashManifestFile(manifestPath);
|
||||
}
|
||||
|
||||
static Future<void> cleanupInactivePreparedNativeDashManifests() async {
|
||||
final activePath = _activeNativeDashManifestPath;
|
||||
final stalePaths = _preparedNativeDashManifestPaths
|
||||
.where((path) => path != activePath)
|
||||
.toList(growable: false);
|
||||
|
||||
for (final path in stalePaths) {
|
||||
_preparedNativeDashManifestPaths.remove(path);
|
||||
await _deleteNativeDashManifestFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
static String? _nativeDashManifestPathFromUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (uri.scheme.toLowerCase() != 'file') {
|
||||
return null;
|
||||
}
|
||||
final path = uri.toFilePath();
|
||||
return path.trim().isEmpty ? null : path;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _deleteNativeDashManifestFile(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stopLiveDecryptedStream() async {
|
||||
final session = _activeLiveDecryptSession;
|
||||
final tempInputPath = _activeLiveTempInputPath;
|
||||
_activeLiveDecryptSession = null;
|
||||
_activeLiveDecryptUrl = null;
|
||||
_activeLiveTempInputPath = null;
|
||||
|
||||
if (session != null) {
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (e) {
|
||||
final sessionId = session.getSessionId();
|
||||
if (sessionId != null) {
|
||||
try {
|
||||
await FFmpegKit.cancel(sessionId);
|
||||
} catch (_) {}
|
||||
}
|
||||
_log.w('Failed to stop live decrypt session cleanly: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (tempInputPath != null && tempInputPath.isNotEmpty) {
|
||||
try {
|
||||
final file = File(tempInputPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> startTidalDashLiveStream({
|
||||
required String manifestPayload,
|
||||
String preferredFormat = 'm4a',
|
||||
}) async {
|
||||
final rawPayload = manifestPayload.trim();
|
||||
if (rawPayload.isEmpty) return null;
|
||||
|
||||
final payload = rawPayload.startsWith('MANIFEST:')
|
||||
? rawPayload.substring('MANIFEST:'.length)
|
||||
: rawPayload;
|
||||
|
||||
final manifestPath = await _writeTempManifestFile(payload);
|
||||
if (manifestPath == null) {
|
||||
_log.e('Failed to prepare Tidal DASH manifest for live stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
await stopLiveDecryptedStream();
|
||||
await stopNativeDashManifestPlayback();
|
||||
|
||||
final attempts = _buildLiveDashFormatAttempts(preferredFormat);
|
||||
for (final format in attempts) {
|
||||
final stream = await _tryStartLiveDashAttempt(
|
||||
manifestPath: manifestPath,
|
||||
format: format,
|
||||
);
|
||||
if (stream != null) {
|
||||
_activeLiveDecryptSession = stream.session;
|
||||
_activeLiveDecryptUrl = stream.localUrl;
|
||||
_activeLiveTempInputPath = manifestPath;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final file = File(manifestPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> _writeTempManifestFile(String payload) async {
|
||||
if (payload.trim().isEmpty) return null;
|
||||
|
||||
Uint8List bytes;
|
||||
try {
|
||||
bytes = base64Decode(payload);
|
||||
} catch (_) {
|
||||
bytes = Uint8List.fromList(utf8.encode(payload));
|
||||
}
|
||||
|
||||
final manifestText = utf8.decode(bytes, allowMalformed: true).trim();
|
||||
if (manifestText.isEmpty) return null;
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final manifestPath =
|
||||
'${tempDir.path}${Platform.pathSeparator}tidal_dash_${DateTime.now().microsecondsSinceEpoch}.mpd';
|
||||
await File(manifestPath).writeAsString(manifestText, flush: true);
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
static List<_LiveDecryptFormat> _buildLiveDashFormatAttempts(
|
||||
String preferredFormat,
|
||||
) {
|
||||
final normalized = preferredFormat.trim().toLowerCase();
|
||||
if (normalized == 'flac') {
|
||||
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
|
||||
}
|
||||
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
|
||||
}
|
||||
|
||||
static Future<bool> _awaitLiveTunnelReady(FFmpegSession session) async {
|
||||
final deadline = DateTime.now().add(_liveTunnelStartupTimeout);
|
||||
var seenRunning = false;
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
final state = await session.getState();
|
||||
if (state == SessionState.running) {
|
||||
seenRunning = true;
|
||||
break;
|
||||
}
|
||||
if (state != SessionState.created) {
|
||||
return false;
|
||||
}
|
||||
await Future<void>.delayed(_liveTunnelStartupPollInterval);
|
||||
}
|
||||
|
||||
if (!seenRunning) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await Future<void>.delayed(_liveTunnelStabilizationDelay);
|
||||
return (await session.getState()) == SessionState.running;
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> _tryStartLiveDashAttempt({
|
||||
required String manifestPath,
|
||||
required _LiveDecryptFormat format,
|
||||
}) async {
|
||||
final port = await _allocateLoopbackPort();
|
||||
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
|
||||
final mimeType = format == _LiveDecryptFormat.flac
|
||||
? 'audio/flac'
|
||||
: 'audio/mp4';
|
||||
final localUrl = 'http://localhost:$port/stream.$ext';
|
||||
|
||||
final commandArguments = <String>[
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-protocol_whitelist',
|
||||
'file,http,https,tcp,tls,crypto,data',
|
||||
'-i',
|
||||
manifestPath,
|
||||
'-map',
|
||||
'0:a:0',
|
||||
'-c:a',
|
||||
'copy',
|
||||
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
|
||||
if (format == _LiveDecryptFormat.m4a) ...[
|
||||
'-movflags',
|
||||
'+frag_keyframe+empty_moov+default_base_moof',
|
||||
'-f',
|
||||
'mp4',
|
||||
],
|
||||
'-content_type',
|
||||
mimeType,
|
||||
'-listen',
|
||||
'1',
|
||||
localUrl,
|
||||
];
|
||||
|
||||
_log.d(
|
||||
'Starting Tidal DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
|
||||
);
|
||||
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
|
||||
final isReady = await _awaitLiveTunnelReady(session);
|
||||
if (isReady) {
|
||||
return LiveDecryptedStreamResult(
|
||||
localUrl: localUrl,
|
||||
format: ext,
|
||||
session: session,
|
||||
);
|
||||
}
|
||||
|
||||
final state = await session.getState();
|
||||
final output = (await session.getOutput() ?? '').trim();
|
||||
if (output.isNotEmpty) {
|
||||
_log.w('Tidal DASH tunnel failed ($ext): $output');
|
||||
} else {
|
||||
_log.w('Tidal DASH tunnel failed ($ext) with session state: $state');
|
||||
}
|
||||
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> startAmazonLiveDecryptedStream({
|
||||
required String encryptedStreamUrl,
|
||||
required String decryptionKey,
|
||||
String preferredFormat = 'flac',
|
||||
}) async {
|
||||
final inputUrl = encryptedStreamUrl.trim();
|
||||
if (inputUrl.isEmpty) return null;
|
||||
|
||||
final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey);
|
||||
if (keyCandidates.isEmpty) {
|
||||
_log.e('No usable decryption key candidates for live stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
await stopLiveDecryptedStream();
|
||||
|
||||
final attempts = _buildLiveDecryptFormatAttempts(preferredFormat);
|
||||
for (final format in attempts) {
|
||||
for (final keyCandidate in keyCandidates) {
|
||||
final stream = await _tryStartLiveDecryptAttempt(
|
||||
inputUrl: inputUrl,
|
||||
decryptionKey: keyCandidate,
|
||||
format: format,
|
||||
);
|
||||
if (stream != null) {
|
||||
_activeLiveDecryptSession = stream.session;
|
||||
_activeLiveDecryptUrl = stream.localUrl;
|
||||
_activeLiveTempInputPath = null;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<_LiveDecryptFormat> _buildLiveDecryptFormatAttempts(
|
||||
String preferredFormat,
|
||||
) {
|
||||
final normalized = preferredFormat.trim().toLowerCase();
|
||||
if (normalized == 'm4a' || normalized == 'mp4' || normalized == 'aac') {
|
||||
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
|
||||
}
|
||||
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> _tryStartLiveDecryptAttempt({
|
||||
required String inputUrl,
|
||||
required String decryptionKey,
|
||||
required _LiveDecryptFormat format,
|
||||
}) async {
|
||||
final port = await _allocateLoopbackPort();
|
||||
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
|
||||
final mimeType = format == _LiveDecryptFormat.flac
|
||||
? 'audio/flac'
|
||||
: 'audio/mp4';
|
||||
final localUrl = 'http://localhost:$port/stream.$ext';
|
||||
|
||||
final commandArguments = <String>[
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-decryption_key',
|
||||
decryptionKey,
|
||||
'-i',
|
||||
inputUrl,
|
||||
'-map',
|
||||
'0:a:0',
|
||||
'-c:a',
|
||||
'copy',
|
||||
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
|
||||
if (format == _LiveDecryptFormat.m4a) ...[
|
||||
'-movflags',
|
||||
'+frag_keyframe+empty_moov+default_base_moof',
|
||||
'-f',
|
||||
'mp4',
|
||||
],
|
||||
'-content_type',
|
||||
mimeType,
|
||||
'-listen',
|
||||
'1',
|
||||
localUrl,
|
||||
];
|
||||
|
||||
_log.d(
|
||||
'Starting live decrypt tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
|
||||
);
|
||||
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
|
||||
final isReady = await _awaitLiveTunnelReady(session);
|
||||
if (isReady) {
|
||||
return LiveDecryptedStreamResult(
|
||||
localUrl: localUrl,
|
||||
format: ext,
|
||||
session: session,
|
||||
);
|
||||
}
|
||||
|
||||
final state = await session.getState();
|
||||
final output = (await session.getOutput() ?? '').trim();
|
||||
if (output.isNotEmpty) {
|
||||
_log.w('Live decrypt attempt failed ($ext): $output');
|
||||
} else {
|
||||
_log.w('Live decrypt attempt failed ($ext) with session state: $state');
|
||||
}
|
||||
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<int> _allocateLoopbackPort() async {
|
||||
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final port = socket.port;
|
||||
await socket.close();
|
||||
return port;
|
||||
}
|
||||
|
||||
static Future<String?> convertFlacToOpus(
|
||||
String inputPath, {
|
||||
String bitrate = '128k',
|
||||
@@ -861,9 +1303,10 @@ class FFmpegService {
|
||||
|
||||
for (final entry in vorbisMetadata.entries) {
|
||||
final key = entry.key.toUpperCase();
|
||||
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
|
||||
switch (key) {
|
||||
switch (normalizedKey) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
break;
|
||||
@@ -878,10 +1321,12 @@ class FFmpegService {
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
id3Map['track'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
id3Map['disc'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
@@ -921,3 +1366,17 @@ class FFmpegResult {
|
||||
required this.output,
|
||||
});
|
||||
}
|
||||
|
||||
enum _LiveDecryptFormat { flac, m4a }
|
||||
|
||||
class LiveDecryptedStreamResult {
|
||||
final String localUrl;
|
||||
final String format;
|
||||
final FFmpegSession session;
|
||||
|
||||
LiveDecryptedStreamResult({
|
||||
required this.localUrl,
|
||||
required this.format,
|
||||
required this.session,
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,12 @@ final _log = AppLogger('PlatformBridge');
|
||||
|
||||
class PlatformBridge {
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
static const _downloadProgressEvents = EventChannel(
|
||||
'com.zarz.spotiflac/download_progress_stream',
|
||||
);
|
||||
static const _libraryScanProgressEvents = EventChannel(
|
||||
'com.zarz.spotiflac/library_scan_progress_stream',
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||
_log.d('parseSpotifyUrl: $url');
|
||||
@@ -48,6 +54,17 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getSpotifyRelatedArtists(
|
||||
String artistId, {
|
||||
int limit = 12,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('getSpotifyRelatedArtists', {
|
||||
'artist_id': artistId,
|
||||
'limit': limit,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> checkAvailability(
|
||||
String spotifyId,
|
||||
String isrc,
|
||||
@@ -113,6 +130,18 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> downloadProgressStream() {
|
||||
return _downloadProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> initItemProgress(String itemId) async {
|
||||
await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
|
||||
}
|
||||
@@ -532,6 +561,17 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerRelatedArtists(
|
||||
String artistId, {
|
||||
int limit = 12,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('getDeezerRelatedArtists', {
|
||||
'artist_id': artistId,
|
||||
'limit': limit,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerMetadata(
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
@@ -1098,6 +1138,18 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
|
||||
return _libraryScanProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel ongoing library scan
|
||||
static Future<void> cancelLibraryScan() async {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ShellNavigationService {
|
||||
static final GlobalKey<NavigatorState> homeTabNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
static final GlobalKey<NavigatorState> storeTabNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
static int _currentTabIndex = 0;
|
||||
static bool _showStoreTab = false;
|
||||
|
||||
static void syncState({
|
||||
required int currentTabIndex,
|
||||
required bool showStoreTab,
|
||||
}) {
|
||||
_currentTabIndex = currentTabIndex;
|
||||
_showStoreTab = showStoreTab;
|
||||
}
|
||||
|
||||
static NavigatorState? activeTabNavigator() {
|
||||
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
|
||||
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
|
||||
if (_showStoreTab && _currentTabIndex == 2) {
|
||||
return storeTabNavigatorKey.currentState;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -28,36 +27,6 @@ class UpdateChecker {
|
||||
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
|
||||
|
||||
static Future<String> _getDeviceArch() async {
|
||||
if (!Platform.isAndroid) return 'unknown';
|
||||
|
||||
try {
|
||||
final cpuInfo = await File('/proc/cpuinfo').readAsString();
|
||||
|
||||
if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) {
|
||||
return 'arm64';
|
||||
}
|
||||
|
||||
final result = await Process.run('uname', ['-m']);
|
||||
final arch = result.stdout.toString().trim().toLowerCase();
|
||||
|
||||
if (arch.contains('aarch64') || arch.contains('arm64')) {
|
||||
return 'arm64';
|
||||
} else if (arch.contains('armv7') || arch.contains('arm')) {
|
||||
return 'arm32';
|
||||
} else if (arch.contains('x86_64')) {
|
||||
return 'x86_64';
|
||||
} else if (arch.contains('x86') || arch.contains('i686')) {
|
||||
return 'x86';
|
||||
}
|
||||
|
||||
return 'arm64';
|
||||
} catch (e) {
|
||||
_log.e('Error detecting arch: $e');
|
||||
return 'arm64';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for updates based on channel preference
|
||||
/// [channel] can be 'stable' or 'preview'
|
||||
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
|
||||
@@ -109,11 +78,7 @@ class UpdateChecker {
|
||||
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
|
||||
|
||||
final deviceArch = await _getDeviceArch();
|
||||
_log.d('Device architecture: $deviceArch');
|
||||
|
||||
String? arm64Url;
|
||||
String? arm32Url;
|
||||
String? universalUrl;
|
||||
|
||||
final assets = releaseData['assets'] as List<dynamic>? ?? [];
|
||||
@@ -128,22 +93,14 @@ class UpdateChecker {
|
||||
}
|
||||
if (name.contains('arm64') || name.contains('v8a')) {
|
||||
arm64Url = downloadUrl;
|
||||
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
||||
arm32Url = downloadUrl;
|
||||
} else if (name.contains('universal')) {
|
||||
universalUrl = downloadUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? apkUrl;
|
||||
if (deviceArch == 'arm64') {
|
||||
apkUrl = arm64Url ?? universalUrl ?? arm32Url;
|
||||
} else if (deviceArch == 'arm32') {
|
||||
apkUrl = arm32Url ?? universalUrl;
|
||||
} else {
|
||||
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
||||
}
|
||||
// Only arm64 is supported; fall back to universal if available
|
||||
final apkUrl = arm64Url ?? universalUrl;
|
||||
|
||||
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');
|
||||
|
||||
|
||||
Reference in new issue
Block a user