From 33cced952ceeff307a4a08f694109c0f5ff07808 Mon Sep 17 00:00:00 2001 From: Umang Namdeo Date: Tue, 20 Oct 2026 02:55:48 +0530 Subject: [PATCH 1/3] Fix audio race conditions, dash memory spikes, and local server auth --- lib/features/addons/dash_assembler.dart | 6 +- lib/features/addons/local_media_server.dart | 45 ++++++++++- lib/features/audio_output/wasapi_engine.dart | 78 +++++++++++++++---- .../innertube/signature_decipher.dart | 33 ++++---- lib/features/player/playback_service.dart | 54 ++++++++----- 5 files changed, 163 insertions(+), 53 deletions(-) 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..e5ba104 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' || @@ -224,10 +244,33 @@ class _Session { // 120s overall budget (healthy assembles finish in ~3-28s): // a stalled CDN fails here into the same-tier retry path // instead of wedging the session under a reading player. + final ok = await DashAssembler.fetchSegments( + _dio, + mediaTemplate: plan.mediaTemplate, + final slots = + List?>.filled(plan.segmentCount, null); + // 120s overall budget (healthy assembles finish in ~3-28s): + // a stalled CDN fails here into the same-tier retry path + // instead of wedging the session under a reading player. final ok = await DashAssembler.fetchSegments( _dio, mediaTemplate: plan.mediaTemplate, startNumber: plan.startNumber, + count: plan.segmentCount, + totalTimeout: const Duration(seconds: 120), + onSegment: (i, bytes) => slots[i] = bytes, + onBatch: (base, end) async { + for (var i = base; i < end; 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(); + }, + ); startNumber: plan.startNumber, count: plan.segmentCount, totalTimeout: const Duration(seconds: 120), onSegment: (i, bytes) => slots[i] = bytes, 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 03bcac9..28c836d 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'; @@ -143,7 +144,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(); @@ -249,7 +252,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) @@ -328,7 +331,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); @@ -351,7 +354,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; @@ -415,7 +418,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; @@ -447,7 +450,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); @@ -462,7 +465,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); @@ -471,7 +474,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); @@ -489,7 +492,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); @@ -502,7 +505,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(')) { @@ -548,7 +551,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; @@ -559,7 +562,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(); @@ -578,7 +581,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; @@ -594,7 +597,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; @@ -701,4 +704,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 19f9bdf..a97a1bc 100644 --- a/lib/features/player/playback_service.dart +++ b/lib/features/player/playback_service.dart @@ -155,7 +155,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, ); @@ -965,6 +965,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 @@ -977,26 +980,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, @@ -1004,6 +1011,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; @@ -1434,6 +1442,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. @@ -1443,16 +1454,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 @@ -1462,6 +1471,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), @@ -1473,8 +1486,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); } @@ -1486,7 +1498,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(); } } @@ -1571,6 +1583,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) { @@ -1694,6 +1707,7 @@ class PlaybackService extends StateNotifier { } return null; } + int? _prevIndex() { if (state.shuffleEnabled && _shuffleOrder.isNotEmpty) { final pos = _shuffleOrder.indexOf(state.currentIndex); @@ -2142,4 +2156,4 @@ final playbackServiceProvider = unawaited(service.disposePlayer()); }); return service; -}); +}); \ No newline at end of file From d2271571ad8429919ffd26d957d3f9b246452037 Mon Sep 17 00:00:00 2001 From: Umang Namdeo Date: Tue, 20 Oct 2026 02:56:40 +0530 Subject: [PATCH 2/3] refactor: adjust parallel downloads limit and enhance error handling in WASAPI engine; implement auth token for local media server From 815e4657e5e65c1631eb52e2536d1b0716633aae Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Tue, 20 Oct 2026 12:02:29 +0530 Subject: [PATCH 3/3] fix(ci): resolve syntax errors in local_media_server and scope wantedQueueKey in playback_service --- lib/features/addons/local_media_server.dart | 18 ------------------ lib/features/player/playback_service.dart | 4 ++-- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/lib/features/addons/local_media_server.dart b/lib/features/addons/local_media_server.dart index e5ba104..1d588f1 100644 --- a/lib/features/addons/local_media_server.dart +++ b/lib/features/addons/local_media_server.dart @@ -244,14 +244,6 @@ class _Session { // 120s overall budget (healthy assembles finish in ~3-28s): // a stalled CDN fails here into the same-tier retry path // instead of wedging the session under a reading player. - final ok = await DashAssembler.fetchSegments( - _dio, - mediaTemplate: plan.mediaTemplate, - final slots = - List?>.filled(plan.segmentCount, null); - // 120s overall budget (healthy assembles finish in ~3-28s): - // a stalled CDN fails here into the same-tier retry path - // instead of wedging the session under a reading player. final ok = await DashAssembler.fetchSegments( _dio, mediaTemplate: plan.mediaTemplate, @@ -270,16 +262,6 @@ class _Session { } await sink.flush(); }, - ); startNumber: plan.startNumber, - count: plan.segmentCount, - totalTimeout: const Duration(seconds: 120), - onSegment: (i, bytes) => slots[i] = bytes, - onBatch: (base, end) async { - for (var i = base; i < end; i++) { - sink.add(slots[i]!); - } - await sink.flush(); - }, ); if (!ok) throw StateError('segment fetch failed'); } finally { diff --git a/lib/features/player/playback_service.dart b/lib/features/player/playback_service.dart index b61734a..f8106ca 100644 --- a/lib/features/player/playback_service.dart +++ b/lib/features/player/playback_service.dart @@ -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();