diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index dafac99..14c346f 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -205,6 +205,35 @@ jobs: if [ -d "$HERE/usr/lib/gio/modules" ]; then export GIO_MODULE_DIR="$HERE/usr/lib/gio/modules" fi + + # Handle legacy OpenGL (< 3.0 / missing glBlitFramebuffer) or explicit flags + USE_SOFTWARE=0 + for arg in "$@"; do + case "$arg" in + --no-enable-impeller|--disable-impeller|--enable-software-rendering|--software) + USE_SOFTWARE=1 + ;; + esac + done + + if [ "$LASTWAVE_DISABLE_IMPELLER" = "1" ] || [ "$LASTWAVE_FORCE_SOFTWARE" = "1" ]; then + USE_SOFTWARE=1 + fi + + if [ "$USE_SOFTWARE" = "0" ] && command -v glxinfo >/dev/null 2>&1; then + GL_VER="$(glxinfo 2>/dev/null | grep -i "OpenGL version string:" | head -n1 | sed -E 's/.*OpenGL version string: ([0-9]+\.[0-9]+).*/\1/')" + if [ -n "$GL_VER" ]; then + MAJOR="$(echo "$GL_VER" | cut -d. -f1)" + if [ "$MAJOR" -lt 3 ] 2>/dev/null; then + USE_SOFTWARE=1 + fi + fi + fi + + if [ "$USE_SOFTWARE" = "1" ]; then + export LIBGL_ALWAYS_SOFTWARE=1 + fi + exec "$HERE/AppRun.real" "$@" EOF chmod +x AppDir/AppRun diff --git a/lib/features/audio_output/output_controller.dart b/lib/features/audio_output/output_controller.dart index d2a9cbf..25414c3 100644 --- a/lib/features/audio_output/output_controller.dart +++ b/lib/features/audio_output/output_controller.dart @@ -72,6 +72,7 @@ class AudioOutputController extends StateNotifier { final WasapiEngine _engine; final FormatNegotiator _negotiator; StreamSubscription>? _hotplug; + Timer? _hotplugDebounce; bool _attached = false; String? _lastLogged; // Fingerprint of every input _rebuildPath consumes, so position-only @@ -103,6 +104,8 @@ class AudioOutputController extends StateNotifier { @override void dispose() { + _hotplugDebounce?.cancel(); + _pendingHotplugEvents.clear(); _hotplug?.cancel(); super.dispose(); } @@ -225,25 +228,40 @@ class AudioOutputController extends StateNotifier { } } + final List<({String reason, String id})> _pendingHotplugEvents = []; + Future _onHotplug(Map event) async { final reason = event['reason']?.toString() ?? ''; final id = event['id']?.toString() ?? ''; wasapiLog('Device event: $reason $id'); - final selected = state.selectedId; - await refreshDevices(); - if (reason == 'removed' && selected.isNotEmpty && selected == id) { - await _ref.read(playbackServiceProvider.notifier).pause(); - state = state.copyWith( - path: state.path.copyWith( - reason: BitPerfectReason.deviceUnavailable, - error: 'DAC disconnected', - ), - ); - return; - } - if (reason == 'added' || reason == 'default' || reason == 'state') { - await applyToPlayer(); - } + _pendingHotplugEvents.add((reason: reason, id: id)); + _hotplugDebounce?.cancel(); + _hotplugDebounce = Timer(const Duration(milliseconds: 300), () async { + final events = + List<({String reason, String id})>.from(_pendingHotplugEvents); + _pendingHotplugEvents.clear(); + final selected = state.selectedId; + await refreshDevices(); + final wasRemoved = events.any((e) => + e.reason == 'removed' && selected.isNotEmpty && selected == e.id); + final shouldApply = events.any((e) => + e.reason == 'added' || + e.reason == 'default' || + e.reason == 'state' || + e.reason == 'property'); + if (shouldApply || wasRemoved) { + await applyToPlayer(); + } + if (wasRemoved) { + await _ref.read(playbackServiceProvider.notifier).pause(); + state = state.copyWith( + path: state.path.copyWith( + reason: BitPerfectReason.deviceUnavailable, + error: 'DAC disconnected', + ), + ); + } + }); } /// True only when every input _rebuildPath reads is unchanged, so the diff --git a/lib/features/innertube/innertube_api.dart b/lib/features/innertube/innertube_api.dart index ad316c8..85fa3fb 100644 --- a/lib/features/innertube/innertube_api.dart +++ b/lib/features/innertube/innertube_api.dart @@ -522,9 +522,25 @@ class InnerTubeMusicApi { }; static const List playerClients = [ - // Limusic fast order: direct-URL clients first (no cipher/poToken), - // ciphered web clients last. The resolver tries VISIONOS alone - // first, then at most 2 more fallbacks — never the full table. + // Direct-URL clients first (no cipher/poToken botguard blockage). + PlayerClient( + 'ANDROID', + '21.26.364', + 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w', + 'com.google.android.youtube/21.26.364 (Linux; U; Android 11) gzip', + osName: 'Android', + osVersion: '11', + ), + PlayerClient( + 'IOS', + '21.26.4', + 'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc', + 'com.google.ios.youtube/21.26.4 (iPhone16,2; U; CPU iOS 18_3_2;)', + osName: 'iPhone', + osVersion: '18.3.2.22D82', + deviceMake: 'Apple', + deviceModel: 'iPhone16,2', + ), PlayerClient( 'VISIONOS', '0.1', @@ -587,24 +603,6 @@ class InnerTubeMusicApi { 'AIzaSyAO_FJ2SlqAz8GlBg1fA54p0wDE7Xk80mU', 'Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/4.0 Chrome/76.0.3809.146 TV Safari/537.36', ), - PlayerClient( - 'IOS', - '21.26.4', - 'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc', - 'com.google.ios.youtube/21.26.4 (iPhone16,2; U; CPU iOS 18_3_2;)', - osName: 'iPhone', - osVersion: '18.3.2.22D82', - deviceMake: 'Apple', - deviceModel: 'iPhone16,2', - ), - PlayerClient( - 'ANDROID', - '21.26.364', - 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w', - 'com.google.android.youtube/21.26.364 (Linux; U; Android 11) gzip', - osName: 'Android', - osVersion: '11', - ), PlayerClient( 'ANDROID_CREATOR', '24.32.100', @@ -697,7 +695,7 @@ class InnerTubeMusicApi { static const int _unknownExpiryTtlMs = 5 * 60 * 1000; // Limusic fast path: one preferred direct-URL client first, // then at most 2 limited fallbacks. Never fan out to the full table. - static const String _fastPreferredClient = 'VISIONOS'; + static const String _fastPreferredClient = 'ANDROID'; static const int _maxStreamClients = 3; static const Duration _fastClientTimeout = Duration(seconds: 8); @@ -4363,14 +4361,10 @@ static String normalize(String s) { if (url == null || url.isEmpty) continue; url = appendPot(url, gvsPoToken); final mime = format['mimeType']?.toString() ?? ''; - if (!mime.toLowerCase().startsWith('audio/')) continue; final codec = _extractCodec(mime); + if (!_isCompatibleAudio(mime, codec)) continue; final bitrate = (format['bitrate'] as num?)?.toInt() ?? 0; - if (!_isCompatibleAudio( - mime.split(';').first, codec)) { - continue; - } final urlExpiry = _urlExpiryMs(url); DateTime? expiresAt; final options = [ @@ -4392,10 +4386,11 @@ static String normalize(String s) { ? 'AAC' : 'AUDIO'); final itag = (format['itag'] as num?)?.toInt() ?? -1; + final cleanMime = mime.split(';').first.trim(); candidates.add(_Candidate( stream: ResolvedStream( url: url, - mimeType: mime.split(';').first, + mimeType: cleanMime, bitrateKbps: (bitrate / 1000).round(), audioCodec: codecLabel, cacheKey: @@ -4411,7 +4406,7 @@ static String normalize(String s) { } candidates.sort((a, b) { final adaptiveOrder = - (a.adaptive ? 1 : 0).compareTo(b.adaptive ? 1 : 0); + (b.adaptive ? 1 : 0).compareTo(a.adaptive ? 1 : 0); if (adaptiveOrder != 0) return adaptiveOrder; return b.bitrate.compareTo(a.bitrate); }); @@ -4493,15 +4488,17 @@ static String normalize(String s) { }(); if (url == null) continue; final mime = f['mimeType']?.toString() ?? ''; - if (!mime.toLowerCase().startsWith('audio/')) { + final codec = _extractCodec(mime); + if (!_isCompatibleAudio(mime, codec)) { continue; } final bitrate = (f['bitrate'] as num?)?.toInt() ?? 0; + final cleanMime = mime.split(';').first.trim(); collected.add(_Candidate( stream: ResolvedStream( url: url, - mimeType: mime.split(';').first, + mimeType: cleanMime, bitrateKbps: (bitrate / 1000).round(), audioCodec: 'AUDIO', cacheKey: 'yt-dl:$videoId:${f['itag']}', @@ -4859,27 +4856,54 @@ static String normalize(String s) { static String _take40(String s) => s.length <= 40 ? s : s.substring(0, 40); + static bool _hasVideoCodec(String mime) { + final lower = mime.toLowerCase(); + return lower.contains('avc1') || + lower.contains('avc3') || + lower.contains('hvc1') || + lower.contains('hev1') || + lower.contains('vp8') || + lower.contains('vp9') || + lower.contains('vp09') || + lower.contains('av01') || + lower.contains('mp4v') || + lower.contains('theora'); + } + static bool _isCompatibleAudio(String mime, String? codec) { final m = mime.toLowerCase(); final c = (codec ?? '').toLowerCase(); - if (m == 'audio/webm') { + if (_hasVideoCodec(m) || _hasVideoCodec(c)) return false; + if (m.startsWith('video/')) return false; + final base = m.split(';').first.trim(); + if (base == 'audio/webm') { return c.isEmpty || c.contains('opus') || c.contains('vorbis'); } - if (m == 'audio/mp4' || m == 'audio/m4a') { + if (base == 'audio/mp4' || base == 'audio/m4a') { return c.isEmpty || c.contains('mp4a') || c.contains('aac'); } - if (m == 'audio/ogg') { + if (base == 'audio/ogg') { return c.isEmpty || c.contains('opus') || c.contains('vorbis'); } - if (m == 'audio/mpeg') return true; + if (base == 'audio/mpeg') return true; return false; } static String? _extractCodec(String mime) { final m = _codecPattern.firstMatch(mime)?.group(1); if (m == null) return null; - final first = m.split(',').first.trim(); - return first.isEmpty ? null : first; + final parts = + m.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty); + for (final p in parts) { + final lp = p.toLowerCase(); + if (lp.contains('mp4a') || + lp.contains('aac') || + lp.contains('opus') || + lp.contains('vorbis')) { + return p; + } + } + return parts.isEmpty ? null : parts.first; } static int? _urlExpiryMs(String url) { diff --git a/lib/features/innertube/signature_decipher.dart b/lib/features/innertube/signature_decipher.dart index 03bcac9..0a0bb2a 100644 --- a/lib/features/innertube/signature_decipher.dart +++ b/lib/features/innertube/signature_decipher.dart @@ -97,13 +97,24 @@ class SignatureDecipher { ), ).timeout(const Duration(seconds: 15)); final html = res.data ?? ''; - jsUrl = RegExp( - r'"jsUrl":"(/s/player/[A-Za-z0-9]+/player_ias\.vflset/[A-Za-z_-]+/base\.js)"') - .firstMatch(html) - ?.group(1); - jsUrl ??= RegExp(r'"jsUrl"\s*:\s*"([^"]+)"') + final match = + RegExp(r'(/s/player/[A-Za-z0-9_./-]+/base\.js)').firstMatch(html); + jsUrl = match?.group(1); + if (jsUrl == null) { + final jsonMatch = + RegExp(r'"jsUrl"\s*:\s*"((?:\\.|[^"\\])*)"').firstMatch(html); + if (jsonMatch != null) { + try { + jsUrl = jsonDecode('"${jsonMatch.group(1)}"') as String?; + } catch (_) { + jsUrl = jsonMatch.group(1)?.replaceAll(r'\/', '/'); + } + } + } + jsUrl ??= RegExp(r'((?:\\?/s\\?/player\\?/[A-Za-z0-9_./\\-]+/base\.js))') .firstMatch(html) - ?.group(1); + ?.group(1) + ?.replaceAll(r'\/', '/'); if (jsUrl != null) break; } catch (_) {} } diff --git a/lib/features/innertube/yt_web_login.dart b/lib/features/innertube/yt_web_login.dart index 253911b..51aac54 100644 --- a/lib/features/innertube/yt_web_login.dart +++ b/lib/features/innertube/yt_web_login.dart @@ -307,10 +307,18 @@ class YtWebLogin { } } + /// Pure check for blank page URLs (about:blank, data:,, or empty). + static bool isBlankUrl(String url) { + final u = url.trim().toLowerCase(); + return u.isEmpty || u == 'about:blank' || u.startsWith('data:,'); + } + static Future _run() async { final w = await _ensureWindow(); if (w == null) return null; try { + // Allow WebView host/controller a moment to attach before initiating navigation + await Future.delayed(const Duration(milliseconds: 300)); // Windows: load directly without the navigation-approval round-trip. // With triggerOnUrlRequestEvent=true (the default) native WebView2 // cancels the navigation and only re-issues it after a Dart @@ -374,10 +382,59 @@ class YtWebLogin { unawaited(webview.onClose.then((_) => closed = true)); var stuckCycles = 0; var autoReloads = 0; + var blankCycles = 0; while (!closed) { + if (Platform.isLinux && !await YtWebviewGuard.isVisible()) { + return null; + } final header = await _readHeader(webview); if (header != null) return header; - if (closed) return null; + if (closed || (Platform.isLinux && !await YtWebviewGuard.isVisible())) { + return null; + } + + // Blank page check: if WebView2 opened on about:blank or dropped navigation, recover + try { + final currentUrlRaw = await webview + .evaluateJavaScript('window.location.href') + .timeout(const Duration(seconds: 4)); + var currentUrl = (currentUrlRaw ?? '').trim(); + if (currentUrl.length >= 2 && + currentUrl.startsWith('"') && + currentUrl.endsWith('"')) { + currentUrl = currentUrl.substring(1, currentUrl.length - 1); + } + if (isBlankUrl(currentUrl)) { + if (closed || (Platform.isLinux && !await YtWebviewGuard.isVisible())) { + return null; + } + blankCycles++; + if (blankCycles >= 2) { + blankCycles = 0; + if (closed || (Platform.isLinux && !await YtWebviewGuard.isVisible())) { + return null; + } + if (kDebugMode) { + debugPrint('YtWebLogin: window stuck on about:blank, re-launching $_loginUrl'); + } + try { + webview.launch(_loginUrl, triggerOnUrlRequestEvent: false); + await webview.evaluateJavaScript('window.location.href = "$_loginUrl";'); + } catch (_) {} + if (!closed && (!Platform.isLinux || await YtWebviewGuard.isVisible())) { + await _foreground(webview); + } + await Future.delayed(const Duration(seconds: 3)); + continue; + } + } else { + blankCycles = 0; + } + } catch (_) {} + if (closed || (Platform.isLinux && !await YtWebviewGuard.isVisible())) { + return null; + } + // Stall check runs sequentially after the cookie read (never // overlapping it). ~5 consecutive hits ≈ 10s stuck. var stuck = false; @@ -472,10 +529,31 @@ class YtWebLogin { } final hasSapisid = pick('__Secure-3PAPISID') != null || + pick('__Secure-1PAPISID') != null || pick('SAPISID') != null; - final loggedIn = pick('LOGIN_INFO') != null; + final loggedIn = pick('LOGIN_INFO') != null || + pick('__Secure-3PSID') != null || + pick('__Secure-1PSID') != null || + pick('SID') != null; if (!hasSapisid || !loggedIn) return null; + // Ensure we do not complete prematurely while on accounts.google.com + // before the redirect to YouTube sets YouTube-specific session cookies. + final hasYoutubeSession = cookies.any((c) { + final domain = (c.domain ?? '').toString().toLowerCase(); + if (!domain.contains('youtube.com')) return false; + final name = _normName(c.name); + final val = _normValue(c.value); + return val.isNotEmpty && + (name == 'LOGIN_INFO' || + name == 'SAPISID' || + name == '__Secure-3PAPISID' || + name == '__Secure-1PAPISID' || + name == 'SID' || + name == '__Secure-3PSID'); + }); + if (!hasYoutubeSession) return null; + // Jar may hold cookies from other Google properties; keep only // YouTube/Google session cookies for the header. final pairs = {}; diff --git a/lib/features/innertube/yt_webview_guard.dart b/lib/features/innertube/yt_webview_guard.dart index ac15963..304a005 100644 --- a/lib/features/innertube/yt_webview_guard.dart +++ b/lib/features/innertube/yt_webview_guard.dart @@ -15,11 +15,41 @@ class YtWebviewGuard { static const _channel = MethodChannel('lastwave/yt_webview'); static bool get isSupported => Platform.isLinux; + static bool _dismissed = false; + static bool _initialized = false; + + static void _ensureInitialized() { + if (_initialized || !isSupported) return; + _initialized = true; + _channel.setMethodCallHandler((call) async { + if (call.method == 'onDismissed') { + _dismissed = true; + } + }); + } + + static bool get isDismissed => _dismissed; + + /// Whether the sign-in window is currently visible and not dismissed. + static Future isVisible() async { + if (!isSupported) return true; + _ensureInitialized(); + if (_dismissed) return false; + try { + final vis = await _channel.invokeMethod('isVisible') ?? true; + if (!vis) _dismissed = true; + return vis; + } catch (_) { + return true; + } + } /// Hide the sign-in window. False when unsupported or not found /// (e.g. never created) — callers fall back to the plugin call. static Future hide() async { if (!isSupported) return false; + _ensureInitialized(); + _dismissed = true; try { return await _channel.invokeMethod('hide') ?? false; } catch (_) { @@ -31,6 +61,8 @@ class YtWebviewGuard { /// caller should create a fresh window instead of reusing the cache. static Future show() async { if (!isSupported) return false; + _ensureInitialized(); + _dismissed = false; try { return await _channel.invokeMethod('show') ?? false; } catch (_) { diff --git a/lib/features/player/playback_service.dart b/lib/features/player/playback_service.dart index 3d3c6fe..0670d00 100644 --- a/lib/features/player/playback_service.dart +++ b/lib/features/player/playback_service.dart @@ -583,7 +583,12 @@ class PlaybackService extends StateNotifier { // -- transport --------------------------------------------------------------- + DateTime _lastToggleTime = DateTime.fromMillisecondsSinceEpoch(0); + Future toggle() async { + final now = DateTime.now(); + if (now.difference(_lastToggleTime).inMilliseconds < 150) return; + _lastToggleTime = now; await ensurePlayer(); if (state.error != null) { await retry(); @@ -602,7 +607,9 @@ class PlaybackService extends StateNotifier { } return; } - await _player?.playOrPause(); + try { + await _player?.playOrPause(); + } catch (_) {} } Future playResume() async { @@ -615,11 +622,15 @@ class PlaybackService extends StateNotifier { } return; } - await _player?.play(); + try { + await _player?.play(); + } catch (_) {} } Future pause() async { - await _player?.pause(); + try { + await _player?.pause(); + } catch (_) {} _schedulePersist(); } @@ -739,7 +750,10 @@ class PlaybackService extends StateNotifier { if (player == null) return; try { final dyn = player.platform as dynamic; - final raw = await dyn.getProperty('audio-out-params') as String? ?? ''; + final raw = await (dyn.getProperty('audio-out-params') as Future) + .timeout(const Duration(seconds: 1), onTimeout: () => '') + as String? ?? + ''; if (raw.isEmpty) return; if (mpvFormatIsFloat(RegExp(r'format=([^\s,}]+)').firstMatch(raw)?.group(1))) { final rateMatch = RegExp(r'samplerate=(\d+)').firstMatch(raw); diff --git a/lib/ui/discover/discover_page.dart b/lib/ui/discover/discover_page.dart index 75e0364..39cefd0 100644 --- a/lib/ui/discover/discover_page.dart +++ b/lib/ui/discover/discover_page.dart @@ -671,7 +671,7 @@ class _GenreTileState extends State<_GenreTile> { ), child: ClipRRect( borderRadius: BorderRadius.circular(6), - child: Container( + child: SizedBox( height: height, child: Stack( fit: StackFit.expand, diff --git a/linux/runner/main.cc b/linux/runner/main.cc index e7c5c54..9de8563 100644 --- a/linux/runner/main.cc +++ b/linux/runner/main.cc @@ -1,6 +1,20 @@ #include "my_application.h" +#include +#include + int main(int argc, char** argv) { + for (int i = 1; i < argc; ++i) { + if (strcmp(argv[i], "--no-enable-impeller") == 0 || + strcmp(argv[i], "--disable-impeller") == 0 || + strcmp(argv[i], "--enable-software-rendering") == 0 || + strcmp(argv[i], "--software") == 0) { + setenv("LIBGL_ALWAYS_SOFTWARE", "1", 1); + setenv("LASTWAVE_DISABLE_IMPELLER", "1", 1); + setenv("FLUTTER_ENABLE_IMPELLER", "0", 1); + break; + } + } g_autoptr(MyApplication) app = my_application_new(); return g_application_run(G_APPLICATION(app), argc, argv); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index c4d4c38..cb8a92e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -83,6 +83,28 @@ static void my_application_activate(GApplication* application) { fl_dart_project_set_dart_entrypoint_arguments( project, self->dart_entrypoint_arguments); + gboolean enable_impeller = TRUE; + const char* disable_env = getenv("LASTWAVE_DISABLE_IMPELLER"); + const char* force_sw = getenv("LASTWAVE_FORCE_SOFTWARE"); + const char* flutter_impeller = getenv("FLUTTER_ENABLE_IMPELLER"); + if ((disable_env != nullptr && strcmp(disable_env, "1") == 0) || + (force_sw != nullptr && strcmp(force_sw, "1") == 0) || + (flutter_impeller != nullptr && strcmp(flutter_impeller, "0") == 0)) { + enable_impeller = FALSE; + } + if (self->dart_entrypoint_arguments != nullptr) { + for (char** arg = self->dart_entrypoint_arguments; *arg != nullptr; ++arg) { + if (strcmp(*arg, "--no-enable-impeller") == 0 || + strcmp(*arg, "--disable-impeller") == 0 || + strcmp(*arg, "--enable-software-rendering") == 0 || + strcmp(*arg, "--software") == 0) { + enable_impeller = FALSE; + break; + } + } + } + fl_dart_project_set_enable_impeller(project, enable_impeller); + FlView* view = fl_view_new(project); GdkRGBA background_color; // Background defaults to black, override it here if necessary, e.g. #00000000 diff --git a/linux/runner/yt_webview_guard.cc b/linux/runner/yt_webview_guard.cc index 2f1121d..8cdf3e2 100644 --- a/linux/runner/yt_webview_guard.cc +++ b/linux/runner/yt_webview_guard.cc @@ -13,13 +13,19 @@ static constexpr const char* kBotGuardTitle = "LastWave BotGuard"; static constexpr const char* kArmedKey = "lastwave-yt-guarded"; static FlMethodChannel* g_channel = nullptr; +static bool g_yt_dismissed = false; // X pressed (or any delete-event): hide instead of destroying. Returning // TRUE stops GTK's default handler, so the WebKit window — and its EGL // context — stays alive and no destroy signal ever fires. static gboolean on_yt_delete_event(GtkWidget* widget, GdkEvent* /*event*/, gpointer /*user_data*/) { + g_yt_dismissed = true; gtk_widget_hide(widget); + if (g_channel != nullptr) { + fl_method_channel_invoke_method(g_channel, "onDismissed", nullptr, nullptr, + nullptr, nullptr); + } return TRUE; } @@ -63,6 +69,7 @@ static void handle_method_call(FlMethodChannel* /*channel*/, const gchar* method = fl_method_call_get_name(method_call); GtkWidget* window = find_yt_window(); if (strcmp(method, "hide") == 0) { + g_yt_dismissed = true; if (window != nullptr) { arm_delete_blocker(window); gtk_widget_hide(window); @@ -72,6 +79,7 @@ static void handle_method_call(FlMethodChannel* /*channel*/, return; } if (strcmp(method, "show") == 0) { + g_yt_dismissed = false; if (window != nullptr) { arm_delete_blocker(window); gtk_widget_show(window); @@ -81,6 +89,12 @@ static void handle_method_call(FlMethodChannel* /*channel*/, fl_method_call_respond_success(method_call, result, nullptr); return; } + if (strcmp(method, "isVisible") == 0) { + gboolean vis = (window != nullptr && gtk_widget_is_visible(window) && !g_yt_dismissed); + g_autoptr(FlValue) result = fl_value_new_bool(vis); + fl_method_call_respond_success(method_call, result, nullptr); + return; + } // BotGuard poToken window (potoken_engine.dart): same upstream destroy // bug as the sign-in window, plus no visibility/move API on Linux, so // it must never be destroyed either — hide and reuse for app lifetime. diff --git a/pubspec.lock b/pubspec.lock index 92ab267..5192683 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1355,7 +1355,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" diff --git a/pubspec.yaml b/pubspec.yaml index f510123..fae3e38 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,6 +73,7 @@ dependencies: desktop_drop: ^0.8.4 lucide_icons_flutter: ^3.1.19 unorm_dart: ^0.3.2 + xml: ^7.0.1 desktop_webview_window: ^0.3.0 meta: ^1.19.0 flutter_lyric: ^3.0.7 diff --git a/test/presence_ipc_test.dart b/test/presence_ipc_test.dart index b7fc5b8..b23b41c 100644 --- a/test/presence_ipc_test.dart +++ b/test/presence_ipc_test.dart @@ -138,6 +138,7 @@ void main() { await dir.delete(recursive: true); } }, + skip: Platform.isWindows, timeout: const Timeout(Duration(seconds: 30)), ); } diff --git a/test/yt_web_login_test.dart b/test/yt_web_login_test.dart index 1aafaf5..9ab8f11 100644 --- a/test/yt_web_login_test.dart +++ b/test/yt_web_login_test.dart @@ -29,4 +29,14 @@ void main() { isTrue, ); }); + + test('isBlankUrl detects blank and about:blank URLs', () { + expect(YtWebLogin.isBlankUrl('about:blank'), isTrue); + expect(YtWebLogin.isBlankUrl('ABOUT:BLANK'), isTrue); + expect(YtWebLogin.isBlankUrl('data:,'), isTrue); + expect(YtWebLogin.isBlankUrl(''), isTrue); + expect(YtWebLogin.isBlankUrl(' '), isTrue); + expect(YtWebLogin.isBlankUrl('https://music.youtube.com/'), isFalse); + expect(YtWebLogin.isBlankUrl('https://accounts.google.com/'), isFalse); + }); } diff --git a/windows/runner/wasapi_engine.cpp b/windows/runner/wasapi_engine.cpp index 4a1b819..add254a 100644 --- a/windows/runner/wasapi_engine.cpp +++ b/windows/runner/wasapi_engine.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #pragma comment(lib, "ole32.lib") @@ -159,7 +160,29 @@ bool ExclusiveSupports(IAudioClient* client, WAVEFORMATEX* format) { return hr == S_OK; } +static std::mutex g_probe_cache_mu; +static std::unordered_map>> g_probe_cache; + +static void InvalidateProbeCache(const std::string& id) { + std::lock_guard lock(g_probe_cache_mu); + g_probe_cache.erase(id); +} + +static inline bool IsKeyEqual(const PROPERTYKEY& a, const PROPERTYKEY& b) { + return a.pid == b.pid && InlineIsEqualGUID(a.fmtid, b.fmtid); +} + void ProbeFormats(IMMDevice* device, DeviceInfo* info) { + { + std::lock_guard lock(g_probe_cache_mu); + const auto it = g_probe_cache.find(info->id); + if (it != g_probe_cache.end()) { + info->exclusive_supported = it->second.first; + info->formats = it->second.second; + return; + } + } + IAudioClient* client = nullptr; HRESULT hr = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, reinterpret_cast(&client)); @@ -210,6 +233,10 @@ void ProbeFormats(IMMDevice* device, DeviceInfo* info) { } } client->Release(); + { + std::lock_guard lock(g_probe_cache_mu); + g_probe_cache[info->id] = {any, found}; + } info->exclusive_supported = any; info->formats = std::move(found); } @@ -256,14 +283,17 @@ class NotificationClient final : public IMMNotificationClient { HRESULT STDMETHODCALLTYPE OnDeviceStateChanged(LPCWSTR id, DWORD) override { + InvalidateProbeCache(WideToUtf8(id)); Notify("state", id); return S_OK; } HRESULT STDMETHODCALLTYPE OnDeviceAdded(LPCWSTR id) override { + InvalidateProbeCache(WideToUtf8(id)); Notify("added", id); return S_OK; } HRESULT STDMETHODCALLTYPE OnDeviceRemoved(LPCWSTR id) override { + InvalidateProbeCache(WideToUtf8(id)); Notify("removed", id); return S_OK; } @@ -273,8 +303,17 @@ class NotificationClient final : public IMMNotificationClient { return S_OK; } HRESULT STDMETHODCALLTYPE OnPropertyValueChanged(LPCWSTR id, - const PROPERTYKEY) override { - Notify("property", id); + const PROPERTYKEY key) override { + // Only forward relevant device identity properties (friendly name, description) + // while ignoring high-frequency playback, volume, and meter property updates + // to prevent audio stutter and UI re-enumeration freezes. + if (IsKeyEqual(key, PKEY_Device_FriendlyName) || + IsKeyEqual(key, PKEY_DeviceInterface_FriendlyName) || + IsKeyEqual(key, PKEY_Device_DeviceDesc) || + IsKeyEqual(key, PKEY_Device_EnumeratorName)) { + InvalidateProbeCache(WideToUtf8(id)); + Notify("property", id); + } return S_OK; }