diff --git a/lib/features/addons/dash_assembler.dart b/lib/features/addons/dash_assembler.dart index 9e72b91..774d1a8 100644 --- a/lib/features/addons/dash_assembler.dart +++ b/lib/features/addons/dash_assembler.dart @@ -22,8 +22,8 @@ class DashAssembler { /// Refuse absurd manifests instead of downloading the internet. static const int maxSegments = 512; - /// Segment fetch parallelism. - static const int parallelDownloads = 12; + /// Segment fetch parallelism (bounded to 6 to prevent memory spikes). + static const int parallelDownloads = 6; /// Assembled-file cache caps (hi-res tracks are ~50–100MB each). static const int maxCachedFiles = 16; @@ -235,4 +235,4 @@ class DashAssembler { } } catch (_) {} } -} +} \ No newline at end of file diff --git a/lib/features/addons/local_media_server.dart b/lib/features/addons/local_media_server.dart index e703407..1d588f1 100644 --- a/lib/features/addons/local_media_server.dart +++ b/lib/features/addons/local_media_server.dart @@ -32,13 +32,21 @@ class LocalMediaServer { final Dio _dio; HttpServer? _server; + String? _authToken; final Map _sessions = {}; + static String _generateAuthToken() { + final rand = Random.secure(); + final bytes = List.generate(16, (_) => rand.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + Future ensureStarted() async { final running = _server; if (running != null) return running.port; final bound = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + _authToken ??= _generateAuthToken(); bound.listen(_route, onError: (_) {}); _server = bound; return bound.port; @@ -75,7 +83,8 @@ class LocalMediaServer { () => _Session(_dio, safe, manifestXml), ); session.kickoff(); - return Uri.parse('http://127.0.0.1:$port/a/$safe.m4a'); + final token = _authToken; + return Uri.parse('http://127.0.0.1:$port/a/$safe.m4a?token=$token'); } catch (_) { return null; } @@ -96,11 +105,22 @@ class LocalMediaServer { Future _route(HttpRequest req) async { try { + // 1. Enforce GET method if (req.method != 'GET') { req.response.statusCode = HttpStatus.methodNotAllowed; await req.response.close(); return; } + + // 2. Validate ephemeral authentication token + final token = req.uri.queryParameters['token']; + if (_authToken == null || token != _authToken) { + req.response.statusCode = HttpStatus.forbidden; + await req.response.close(); + return; + } + + // 3. Validate path structure final segs = req.uri.pathSegments; if (segs.length != 2 || segs[0] != 'a' || @@ -233,7 +253,12 @@ class _Session { onSegment: (i, bytes) => slots[i] = bytes, onBatch: (base, end) async { for (var i = base; i < end; i++) { - sink.add(slots[i]!); + final chunk = slots[i]; + if (chunk != null) { + sink.add(chunk); + // Null out immediately after writing so GC can reclaim the memory + slots[i] = null; + } } await sink.flush(); }, diff --git a/lib/features/audio_output/wasapi_engine.dart b/lib/features/audio_output/wasapi_engine.dart index e8bf300..8d4eba5 100644 --- a/lib/features/audio_output/wasapi_engine.dart +++ b/lib/features/audio_output/wasapi_engine.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer' as developer; import 'dart:io'; @@ -24,75 +25,124 @@ class WasapiEngine { return event.map((k, v) => MapEntry(k.toString(), v)); } return {}; + }).handleError((error) { + wasapiLog('deviceEvents stream error: $error'); }); } Future> enumerateDevices() async { if (!isSupported) return const []; try { - final raw = await _channel.invokeMethod>('enumerateDevices'); + final raw = await _channel + .invokeMethod>('enumerateDevices') + .timeout(const Duration(seconds: 4)); if (raw == null) return const []; - return raw - .whereType() - .map(DacDevice.fromJson) - .toList(); + return raw.whereType().map(DacDevice.fromJson).toList(); + } on TimeoutException { + wasapiLog('enumerateDevices timed out'); + return const []; } on PlatformException catch (e) { wasapiLog('enumerateDevices failed: ${e.message}'); return const []; + } catch (e) { + wasapiLog('Unexpected error in enumerateDevices: $e'); + return const []; } } Future probeDevice(String id) async { - if (!isSupported) return null; + if (!isSupported || id.trim().isEmpty) return null; try { final raw = await _channel.invokeMethod>( 'probeDevice', {'id': id}, - ); + ).timeout(const Duration(seconds: 5)); + if (raw == null) return null; return DacDevice.fromJson(raw); + } on TimeoutException { + wasapiLog('probeDevice timed out for device $id'); + return null; } on PlatformException catch (e) { - wasapiLog('probeDevice failed: ${e.message}'); + wasapiLog('probeDevice failed for $id:${e.message}'); + return null; + } catch (e) { + wasapiLog('Unexpected error in probeDevice for $id:$e'); return null; } } + /// Verifies whether the specified DAC device supports a given PCM format + /// before requesting exclusive mode from libmpv. + Future isFormatSupported({ + required String id, + required int sampleRate, + required int channels, + required int bitDepth, + }) async { + if (!isSupported || id.trim().isEmpty) return false; + try { + final supported = await _channel.invokeMethod('isFormatSupported', { + 'id': id, + 'sampleRate': sampleRate, + 'channels': channels, + 'bitDepth': bitDepth, + }).timeout(const Duration(seconds: 3)); + + return supported ?? false; + } catch (e) { + wasapiLog('Format check error ($sampleRate Hz, $bitDepth-bit):$e'); + return false; + } + } + Future setHardwareVolume(String id, double scalar) async { - if (!isSupported) return false; + if (!isSupported || id.trim().isEmpty) return false; try { await _channel.invokeMethod('setHardwareVolume', { 'id': id, 'scalar': scalar.clamp(0.0, 1.0), - }); + }).timeout(const Duration(seconds: 2)); return true; } on PlatformException catch (e) { wasapiLog('setHardwareVolume failed: ${e.message}'); return false; + } catch (_) { + return false; } } Future getHardwareVolume(String id) async { - if (!isSupported) return null; + if (!isSupported || id.trim().isEmpty) return null; try { - final v = await _channel.invokeMethod('getHardwareVolume', {'id': id}); + final v = await _channel.invokeMethod('getHardwareVolume', { + 'id': id, + }).timeout(const Duration(seconds: 2)); return v?.toDouble(); } on PlatformException catch (e) { wasapiLog('getHardwareVolume failed: ${e.message}'); return null; + } catch (_) { + return null; } } Future defaultDeviceId() async { if (!isSupported) return ''; try { - return await _channel.invokeMethod('defaultDeviceId') ?? ''; + return await _channel + .invokeMethod('defaultDeviceId') + .timeout(const Duration(seconds: 2)) ?? + ''; } on PlatformException catch (e) { wasapiLog('defaultDeviceId failed: ${e.message}'); return ''; + } catch (_) { + return ''; } } } void wasapiLog(String message) { developer.log(message, name: 'WASAPI'); -} +} \ No newline at end of file diff --git a/lib/features/innertube/signature_decipher.dart b/lib/features/innertube/signature_decipher.dart index 0a0bb2a..ff8ca22 100644 --- a/lib/features/innertube/signature_decipher.dart +++ b/lib/features/innertube/signature_decipher.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:isolate'; import 'package:dio/dio.dart'; import 'package:meta/meta.dart'; @@ -154,7 +155,9 @@ class SignatureDecipher { ).timeout(const Duration(seconds: 20)); final js = res.data ?? ''; if (js.isEmpty) return null; - final script = _parse(jsUrl, js); + + // Offload regex parsing to an isolate worker thread to prevent main UI thread stutters + final script = await Isolate.run(() => _parse(jsUrl, js)); if (script == null) return null; if (_byJsUrl.length > 8) { _byJsUrl.clear(); @@ -260,7 +263,7 @@ class SignatureDecipher { '\\.get\\("n"\\)\\)&&\\($_sc=($_mc)(?:\\[(\\d+)\\])?\\($_sc\\)'), ]; - PlayerScript? _parse(String jsUrl, String js) { + static PlayerScript? _parse(String jsUrl, String js) { // STS lives in the player script (NewPipe: signatureTimestamp[=:](\d+)). final sts = RegExp(r'signatureTimestamp[=:](\d+)') .firstMatch(js) @@ -339,7 +342,7 @@ class SignatureDecipher { /// Extract `NAME=function(...)` source with balanced braces /// (lexer approach, mirroring NewPipe). - _FuncCode _extractFunction(String js, String name) { + static _FuncCode _extractFunction(String js, String name) { final idx = js.indexOf('$name=function'); if (idx < 0) throw const FormatException('no func'); final openIdx = js.indexOf('{', idx); @@ -362,7 +365,7 @@ class SignatureDecipher { /// Strip NewPipe-style early-return guards, bind the array /// variable + extra literals, and compile op statements. - _Prepared _prepare(String js, String body, List params, + static _Prepared _prepare(String js, String body, List params, {String? extraLiteral}) { final env = {}; late final String arrayVar; @@ -426,7 +429,7 @@ class SignatureDecipher { return _Prepared(ops, env, arrayVar); } - String? _findNFunc(String js) { + static String? _findNFunc(String js) { for (final pattern in _nNamePatterns) { final m = pattern.firstMatch(js); if (m == null) continue; @@ -458,7 +461,7 @@ class SignatureDecipher { // -- helper objects + op classification -------------------------------------- - DecipherOp _resolveOp(String js, String obj, String method, + static DecipherOp _resolveOp(String js, String obj, String method, String? argSrc, Map outerEnv) { final helper = _helperBody(js, obj); final member = _memberBody(helper, method); @@ -473,7 +476,7 @@ class SignatureDecipher { return _classify(member, env); } - String _helperBody(String js, String obj) { + static String _helperBody(String js, String obj) { // Helper objects are `var NAME={...};` (2+ char names). final m = RegExp('(?:var\\s+)?${RegExp.escape(obj)}\\s*=\\s*\\{') .firstMatch(js); @@ -482,7 +485,7 @@ class SignatureDecipher { return _balanced(js, start); } - String _memberBody(String helper, String method) { + static String _memberBody(String helper, String method) { var m = RegExp(RegExp.escape(method) + r'\s*:\s*function\s*\(([^)]*)\)\s*\{') .firstMatch(helper); @@ -500,7 +503,7 @@ class SignatureDecipher { throw FormatException('no member $method'); } - List _memberParams(String helper, String method) { + static List _memberParams(String helper, String method) { final m = RegExp(RegExp.escape(method) + r'\s*(?::\s*function)?\s*\(([^)]*)\)\s*\{') .firstMatch(helper); @@ -513,7 +516,7 @@ class SignatureDecipher { .toList(); } - DecipherOp _classify(String body, Map env) { + static DecipherOp _classify(String body, Map env) { final b = body.replaceAll(RegExp(r'\s+'), ''); if (b.contains('.reverse(')) return const DecipherOp(DecipherOpType.reverse); if (b.contains('.splice(')) { @@ -559,7 +562,7 @@ class SignatureDecipher { throw const FormatException('unknown op'); } - DecipherOp _spliceOp(List args, Map env) { + static DecipherOp _spliceOp(List args, Map env) { if (args.length >= 2) { final start = _evalInt(args[0], env, 0) ?? 0; final count = _evalInt(args[1], env, 0) ?? 0; @@ -570,7 +573,7 @@ class SignatureDecipher { return DecipherOp(DecipherOpType.splice, [start]); } - List _splitArgs(String src) { + static List _splitArgs(String src) { final out = []; var depth = 0; final buf = StringBuffer(); @@ -589,7 +592,7 @@ class SignatureDecipher { return out; } - int? _evalInt(String expr, Map env, int len) { + static int? _evalInt(String expr, Map env, int len) { final e = expr.trim(); final lit = int.tryParse(e); if (lit != null) return lit; @@ -605,7 +608,7 @@ class SignatureDecipher { return null; } - String _balanced(String src, int openIdx) { + static String _balanced(String src, int openIdx) { var depth = 0; var inStr = ''; var escaped = false; @@ -712,4 +715,4 @@ class _Prepared { extension _Let on T { R let(R Function(T) fn) => fn(this); -} +} \ No newline at end of file diff --git a/lib/features/player/playback_service.dart b/lib/features/player/playback_service.dart index 64973f6..f8106ca 100644 --- a/lib/features/player/playback_service.dart +++ b/lib/features/player/playback_service.dart @@ -177,7 +177,7 @@ class PlaybackService extends StateNotifier { final path = _crumbPath; if (path == null) return; File(path).writeAsStringSync( - '${DateTime.now().toIso8601String()} $line\n', + '${DateTime.now().toIso8601String()}$line\n', mode: FileMode.append, flush: true, ); @@ -1364,10 +1364,10 @@ class PlaybackService extends StateNotifier { _resolvingIndex = index; _playbackAttempt = attempt; final totalSw = Stopwatch()..start(); + final track = state.queue[index]; + final wantedQueueKey = track.queueKey; int resolveMs = 0; try { - final track = state.queue[index]; - final wantedQueueKey = track.queueKey; _activeQueueKey = wantedQueueKey; // Arm the first-audio clock: resolve-start → first playing=true. _audioClockStart = DateTime.now(); @@ -1428,6 +1428,9 @@ class PlaybackService extends StateNotifier { return; } if (stream == null) { + // Stale check before triggering retry or skip + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; + // Retry YouTube-only only when the first pass could have taken // the lossless path — otherwise it repeats the identical // best-effort search (up to ~15s of spinner for nothing) on @@ -1440,26 +1443,30 @@ class PlaybackService extends StateNotifier { return; } _unavailable.add(track.queueKey); + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; await _skipUnavailable(index); return; } - _resolving = false; + final openSw = Stopwatch()..start(); await _open(track, stream, generation, wantedQueueKey); openSw.stop(); totalSw.stop(); - if (generation == _resolveGeneration) { - _logTiming('play key=$wantedQueueKey path=remote ' - 'playcache_hit=${cached != null} ' - 'resolve_ms=$resolveMs ' - 'mpv_open_ms=${openSw.elapsedMilliseconds} ' - 'total_ms=${totalSw.elapsedMilliseconds}'); - } - if (generation != _resolveGeneration) return; + + // Guard: if user clicked next during _open, exit immediately + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; + + _logTiming('play key=$wantedQueueKey path=remote ' + 'playcache_hit=${cached != null} ' + 'resolve_ms=$resolveMs ' + 'mpv_open_ms=${openSw.elapsedMilliseconds} ' + 'total_ms=${totalSw.elapsedMilliseconds}'); + _prefetchNeighbors(); _maybeRefillRadio(); - } catch (_) { - if (generation != _resolveGeneration) return; + } catch (e) { + // Strictly ignore errors from outdated skip requests + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; state = state.copyWith( isPlaying: false, isBuffering: false, @@ -1467,6 +1474,7 @@ class PlaybackService extends StateNotifier { error: 'Could not load this track. Press Play to retry.', ); } finally { + // Only release the resolving lock if THIS specific generation is still the latest if (generation == _resolveGeneration) { _resolving = false; _resolvingIndex = -1; @@ -1898,6 +1906,9 @@ class PlaybackService extends StateNotifier { int stopMs = 0; int loadMs = 0; await _serializedMpv(() async { + // Stale check right before native operations + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; + // Always tear down before loading: opening over a live stream // (or racing its teardown) corrupts the Windows libmpv heap — // first track plays, the next dies in ntdll. @@ -1907,16 +1918,14 @@ class PlaybackService extends StateNotifier { } catch (_) {} stopSw.stop(); stopMs = stopSw.elapsedMilliseconds; - if (generation != _resolveGeneration) return; - if (_activeQueueKey != wantedQueueKey) return; + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; if (wasDash) { // Proven crash window (mpv.log): the previous DASH demuxer // is still unwinding when loadfile arrives — the second // manifest dies mid-probe ~10ms after open. Bounded grace. _crumb('open key=$wantedQueueKey dash-grace'); await Future.delayed(const Duration(milliseconds: 500)); - if (generation != _resolveGeneration) return; - if (_activeQueueKey != wantedQueueKey) return; + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; } // Stamp open-issue BEFORE the call, not after it returns: // mpv events (pause=no) and the open() reply travel on separate @@ -1926,6 +1935,10 @@ class PlaybackService extends StateNotifier { _activeQueueKey == wantedQueueKey) { _audioOpenDoneAt = DateTime.now(); } + + // Final sanity check before issuing native loadfile + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; + final loadSw = Stopwatch()..start(); await _player?.open( Media(playUrl, httpHeaders: stream.requestHeaders), @@ -1937,8 +1950,7 @@ class PlaybackService extends StateNotifier { }); _logTiming('mpv key=$wantedQueueKey kind=$kind ' 'stop_ms=$stopMs load_ms=$loadMs dash_grace=${wasDash ? 500 : 0}'); - if (generation != _resolveGeneration) return; - if (_activeQueueKey != wantedQueueKey) return; + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; if (state.speed != 1.0) { await _player?.setRate(state.speed); } @@ -1950,7 +1962,7 @@ class PlaybackService extends StateNotifier { debugPrint('LastWave-Player: open stack: $st'); _dumpMpvLog('_open.exception'); } - if (generation != _resolveGeneration) return; + if (generation != _resolveGeneration || _activeQueueKey != wantedQueueKey) return; await _onPlayerError(); } } @@ -2042,6 +2054,7 @@ class PlaybackService extends StateNotifier { _unavailable.add(track.queueKey); await _skipUnavailable(state.currentIndex); } + Future _skipUnavailable(int failedIndex) async { final nextIndex = _nextIndex(skipUnavailable: true); if (nextIndex == null) { @@ -2170,6 +2183,7 @@ class PlaybackService extends StateNotifier { } return null; } + int? _prevIndex() { if (state.shuffleEnabled && _shuffleOrder.isNotEmpty) { final pos = _shuffleOrder.indexOf(state.currentIndex); @@ -2649,4 +2663,4 @@ final playbackServiceProvider = unawaited(service.disposePlayer()); }); return service; -}); +}); \ No newline at end of file