diff --git a/.github/contributing.md b/.github/contributing.md new file mode 100644 index 0000000..5b215e3 --- /dev/null +++ b/.github/contributing.md @@ -0,0 +1,44 @@ +# Contributing to Aqloss + +Thanks for wanting to contribute. Here's what you need to know. + +## Project structure + +``` +lib/ → Flutter frontend (Dart) +rust/src/ → Audio backend (Rust) + audio_engine.rs → playback core + discord_rpc.rs → Discord rich presence + api.rs → bridge between Rust and Flutter +``` + +## Getting started + +1. Make sure you have Flutter and Rust installed +2. Fork the repo and clone your fork +3. Run `flutter pub get` in the root +4. Run `cargo build` inside `rust/` +5. Start the app with `flutter run` + +## Before submitting a PR + +- Keep changes focused — one fix or feature per PR +- If you're fixing a bug, mention the issue number (`Fixes #123`) +- Test on your platform at minimum; note if you can't test others +- For Rust changes, make sure there are no new `unwrap()`/`expect()` on hot paths — use `?` or `global_opt()` instead +- For Flutter changes, test both desktop and mobile behavior if relevant (they can differ, e.g. context menus) +- Short commit messages are fine, just be descriptive enough + +## Code style + +- Rust: `cargo fmt` before committing +- Dart: `dart format .` before committing +- Comments are welcome but keep them brief — `// ...` style, not paragraph essays + +## What to work on + +Check issues labeled `good first issue` or `help wanted`. If you want to work on something, leave a comment first so we don't duplicate effort. + +## Reporting bugs + +Use the bug report template. Logs are really helpful — especially backend logs and `RUST_BACKTRACE=1` output for panics. diff --git a/.github/issue_template/bug_report.md b/.github/issue_template/bug_report.md new file mode 100644 index 0000000..28f90e4 --- /dev/null +++ b/.github/issue_template/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug Report +about: Something is broken or not working as expected +labels: bug, needs reproduction +--- + +## What happened + + + +## Steps to reproduce + +1. +2. +3. + +## Logs + + + +``` +(paste logs here) +``` + +## Environment + +- **OS:** +- **Aqloss version:** +- **Audio backend:** +- **File format (if audio-related):** diff --git a/.github/issue_template/feature_request.md b/.github/issue_template/feature_request.md new file mode 100644 index 0000000..7bc131e --- /dev/null +++ b/.github/issue_template/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature Request +about: Suggest a new feature or improvement +labels: feature +--- + +## What do you want? + + + +## Why is this useful? + + + +## Any ideas on implementation? + + diff --git a/README.md b/README.md index 2dcf3b0..45179dd 100755 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ A music player built around a Rust audio engine, with optional WASAPI Exclusive [![Release](https://img.shields.io/github/v/release/nokarin-dev/aqloss?style=for-the-badge&color=4F8EF7)](https://github.com/nokarin-dev/aqloss/releases/latest) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg?style=for-the-badge)](LICENSE) [![Flutter](https://img.shields.io/badge/Flutter-3.41-02569B?style=for-the-badge&logo=flutter)](https://flutter.dev) -[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-02569B?style=for-the-badge)](#download) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Android-02569B?style=for-the-badge)](#download) [![Total Downloads](https://img.shields.io/github/downloads/nokarin-dev/aqloss/total?style=for-the-badge&logoColor=%3D&color=3471eb)](https://github.com/nokarin-dev/aqloss/releases) +[![Flathub Downloads](https://img.shields.io/flathub/downloads/xyz.nokarin.aqloss?style=for-the-badge&label=flathub%40installs&color=0451b8)](https://github.com/nokarin-dev/aqloss/releases/latest) [![Latest Downloads](https://img.shields.io/github/downloads/nokarin-dev/aqloss/latest/total?style=for-the-badge&color=3d47d4)](https://github.com/nokarin-dev/aqloss/releases/latest) [![Test Status](https://img.shields.io/github/actions/workflow/status/nokarin-dev/aqloss/build-test.yml?style=for-the-badge&label=test%20build&color=22316e)](https://github.com/nokarin-dev/aqloss/actions/workflows/build-test.yml) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2b6c601..4f607d7 100755 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + + + diff --git a/changes.md b/changes.md index 9d155f1..a07b62b 100755 --- a/changes.md +++ b/changes.md @@ -8,12 +8,25 @@ This project loosely follows Keep a Changelog and uses Semantic Versioning. ## [Unreleased] +### Added + +- find button discord RPC now links to YouTube Music search +- [Android] Storage permissions handler +- [Android] URI path resolution +- [Android] Folder manager access on mobile + ### Fixed - Call backend only on drag end to prevent seek throttle - All button now should has pointer now - Discord button label overflow - Added helpers to prevent backend crash +- [Android] Library scan empty +- [Android] Status bar overlap +- [Android] window_manager crash on Android +- [Android] Spectrum negative padding +- [Android] Using ndk context to open audio output for cpal +- [Android] Overflow on grid item --- diff --git a/lib/app.dart b/lib/app.dart index 428e8ad..cd34051 100755 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart' hide ThemeMode; import 'package:flutter/material.dart' as theme; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,6 +7,9 @@ import 'package:aqloss/widgets/settings_watcher.dart'; import 'package:window_manager/window_manager.dart'; import 'screens/home_screen.dart'; +bool get _isDesktop => + Platform.isWindows || Platform.isLinux || Platform.isMacOS; + class AqlossApp extends ConsumerStatefulWidget { const AqlossApp({super.key}); @@ -19,19 +23,24 @@ class _AqlossAppState extends ConsumerState with WindowListener { @override void initState() { super.initState(); - windowManager.addListener(this); - _checkMaximize(); + if (_isDesktop) { + windowManager.addListener(this); + _checkMaximize(); + } } @override void dispose() { - windowManager.removeListener(this); + if (_isDesktop) windowManager.removeListener(this); super.dispose(); } Future _checkMaximize() async { - final fs = await windowManager.isMaximized(); - if (mounted) setState(() => _isMaximize = fs); + if (!_isDesktop) return; + try { + final fs = await windowManager.isMaximized(); + if (mounted) setState(() => _isMaximize = fs); + } catch (_) {} } @override @@ -57,12 +66,10 @@ class _AqlossAppState extends ConsumerState with WindowListener { theme: _buildLightTheme(), darkTheme: _buildDarkTheme(), builder: (context, child) { - return ClipRRect( - borderRadius: _isMaximize - ? BorderRadius.zero - : BorderRadius.circular(12.0), - child: child, - ); + final radius = _isDesktop && !_isMaximize + ? BorderRadius.circular(12.0) + : BorderRadius.zero; + return ClipRRect(borderRadius: radius, child: child); }, home: const SettingsWatcher(child: HomeScreen()), ); diff --git a/lib/main.dart b/lib/main.dart index 4f0807c..4f0fbee 100755 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,15 +39,22 @@ void main() async { runApp(const ProviderScope(child: AqlossApp())); WidgetsBinding.instance.addPostFrameCallback((_) async { - final (deviceId, exclusive, volume) = await _loadStartupPrefs(); - final prefs = await SharedPreferences.getInstance(); - final settings = await _loadSettingsState(prefs); - await AudioService.init( - deviceId: deviceId, - exclusive: exclusive, - volume: volume, - settings: settings, - ); + if (Platform.isAndroid) { + await Future.delayed(const Duration(milliseconds: 800)); + } + try { + final (deviceId, exclusive, volume) = await _loadStartupPrefs(); + final prefs = await SharedPreferences.getInstance(); + final settings = await _loadSettingsState(prefs); + await AudioService.init( + deviceId: deviceId, + exclusive: exclusive, + volume: volume, + settings: settings, + ); + } catch (e, st) { + print('[aqloss] main init error: $e\n$st'); + } }); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index aa54ae6..eff798a 100755 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:window_manager/window_manager.dart'; import 'package:file_picker/file_picker.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:aqloss/util/android_path_helper.dart'; import 'library_screen.dart'; import 'player_screen.dart'; import 'settings_screen.dart'; @@ -166,39 +167,44 @@ class _HomeScreenState extends ConsumerState with WindowListener { autofocus: true, onKeyEvent: _handleKey, child: Scaffold( - body: ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: Column( - children: [ - if (_isDesktop) _CustomTitleBar(isMaximized: _isMaximized), - Expanded( - child: isWide - ? Row( - children: [ - _SideNav( - route: _route, - collapsed: _sidebarCollapsed, - onSelect: (r) => setState(() => _route = r), - onToggleCollapse: _toggleSidebar, - ), - Expanded( - child: Column( - children: [ - Expanded(child: _buildScreen()), - if (hasTrack && _route != 0) - MiniPlayerBar( - onTap: () => setState(() => _route = 0), - ), - ], + body: SafeArea( + top: !_isDesktop, + bottom: false, + child: ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + if (_isDesktop) _CustomTitleBar(isMaximized: _isMaximized), + Expanded( + child: isWide + ? Row( + children: [ + _SideNav( + route: _route, + collapsed: _sidebarCollapsed, + onSelect: (r) => setState(() => _route = r), + onToggleCollapse: _toggleSidebar, ), - ), - ], - ) - : _buildScreen(), - ), - ], + Expanded( + child: Column( + children: [ + Expanded(child: _buildScreen()), + if (hasTrack && _route != 0) + MiniPlayerBar( + onTap: () => setState(() => _route = 0), + ), + ], + ), + ), + ], + ) + : _buildScreen(), + ), + ], + ), ), ), + // SafeArea bottomNavigationBar: isWide ? null : _MobileNavBar( @@ -1174,11 +1180,28 @@ class _FolderManagerDialog extends ConsumerWidget { const _FolderManagerDialog(); Future _addFolder(BuildContext context, WidgetRef ref) async { + if (Platform.isAndroid) { + final granted = await requestAndroidStoragePermission(); + if (!granted) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Storage permission is required to scan music folders', + ), + ), + ); + } + return; + } + } + final result = await FilePicker.getDirectoryPath( dialogTitle: 'Select music folder', ); if (result != null) { - ref.read(libraryProvider.notifier).addFolder(result); + final path = resolveAndroidPath(result); + ref.read(libraryProvider.notifier).addFolder(path); } } diff --git a/lib/screens/library_screen.dart b/lib/screens/library_screen.dart index 24bf478..5499c24 100755 --- a/lib/screens/library_screen.dart +++ b/lib/screens/library_screen.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:aqloss/util/android_path_helper.dart'; import 'package:aqloss/models/track.dart'; import 'package:aqloss/widgets/now_playing_header.dart'; import 'package:aqloss/providers/playlist_provider.dart'; @@ -79,6 +82,19 @@ class _LibraryScreenState extends ConsumerState { .read(settingsProvider.notifier) .setLibraryViewMode(LibraryViewMode.grid), ), + if (!Platform.isWindows && + !Platform.isLinux && + !Platform.isMacOS) ...[ + const SizedBox(width: 2), + _ViewModeButton( + icon: Icons.folder_open_rounded, + active: false, + onTap: () => showDialog( + context: context, + builder: (_) => const _FolderManagerShim(), + ), + ), + ], ], ), ), @@ -417,12 +433,14 @@ class _TrackList extends ConsumerWidget { final playerNotifier = ref.read(playerProvider.notifier); final playlists = ref.watch(playlistProvider); final playlistNotifier = ref.read(playlistProvider.notifier); + final isDesktop = + Platform.isWindows || Platform.isLinux || Platform.isMacOS; if (viewMode == LibraryViewMode.grid) { return GridView.builder( padding: const EdgeInsets.fromLTRB(12, 4, 12, 16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isDesktop ? 8 : 4, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.78, @@ -431,8 +449,14 @@ class _TrackList extends ConsumerWidget { itemBuilder: (ctx, i) => TrackGridItem( track: tracks[i], onTap: () => playerNotifier.loadWithQueue(tracks[i], tracks), - onLongPress: () => - _showOptions(ctx, tracks[i], playlists, playlistNotifier), + // mobile + onLongPress: isDesktop + ? null + : () => _showOptions(ctx, tracks[i], playlists, playlistNotifier), + // desktop + onSecondaryTap: isDesktop + ? () => _showOptions(ctx, tracks[i], playlists, playlistNotifier) + : null, ), ); } @@ -444,8 +468,14 @@ class _TrackList extends ConsumerWidget { track: tracks[i], index: i, onTap: () => playerNotifier.loadWithQueue(tracks[i], tracks), - onLongPress: () => - _showOptions(ctx, tracks[i], playlists, playlistNotifier), + // mobile + onLongPress: isDesktop + ? null + : () => _showOptions(ctx, tracks[i], playlists, playlistNotifier), + // desktop + onSecondaryTap: isDesktop + ? () => _showOptions(ctx, tracks[i], playlists, playlistNotifier) + : null, ), ); } @@ -680,3 +710,126 @@ class _ViewModeButton extends StatelessWidget { ); } } + +// Folder manager dialog for mobile +class _FolderManagerShim extends ConsumerWidget { + const _FolderManagerShim(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final library = ref.watch(libraryProvider); + final folders = library.folders; + final isScanning = library.status == LibraryStatus.scanning; + final cs = Theme.of(context).colorScheme; + + return Dialog( + backgroundColor: Theme.of(context).cardColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460, minWidth: 300), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Music Folders', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => Navigator.pop(context), + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 12), + if (folders.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No folders added yet.', + style: TextStyle( + color: cs.onSurface.withValues(alpha: 0.45), + fontSize: 13, + ), + ), + ) + else + ...folders.map((f) { + final parts = f.replaceAll('\\', '/').split('/'); + final label = parts.length <= 2 + ? f + : '…/${parts.sublist(parts.length - 2).join('/')}'; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + label, + style: const TextStyle(fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: Icon( + Icons.remove_circle_outline, + size: 18, + color: cs.onSurface.withValues(alpha: 0.4), + ), + onPressed: () => + ref.read(libraryProvider.notifier).removeFolder(f), + ), + ); + }), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: isScanning + ? null + : () async { + await _pickAndAdd(context, ref); + }, + icon: isScanning + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add_rounded, size: 18), + label: Text(isScanning ? 'Scanning…' : 'Add Folder'), + ), + ), + ], + ), + ), + ), + ); + } +} + +Future _pickAndAdd(BuildContext context, WidgetRef ref) async { + if (Platform.isAndroid) { + final granted = await requestAndroidStoragePermission(); + if (!granted) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Storage permission required to scan folders'), + ), + ); + } + return; + } + } + final result = await FilePicker.getDirectoryPath( + dialogTitle: 'Select music folder', + ); + if (result != null && context.mounted) { + final path = resolveAndroidPath(result); + ref.read(libraryProvider.notifier).addFolder(path); + } +} diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index b904e32..7d444c9 100755 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -54,31 +54,45 @@ class AudioService { }) async { _engineReady = false; if (volume != null) _cachedVolume = volume.clamp(0.0, 1.0); - try { - if (deviceId != null) { - await Future( - () => backend.initEngineWithDevice( - deviceId: deviceId, - exclusive: exclusive, - ), - ).timeout(const Duration(seconds: 8)); - } else { - await Future( - () => backend.initEngine(), - ).timeout(const Duration(seconds: 8)); + + const delays = [0, 1000, 2000]; + for (int attempt = 0; attempt < delays.length; attempt++) { + if (delays[attempt] > 0) { + await Future.delayed(Duration(milliseconds: delays[attempt])); } - } catch (e) { - Logger.warnAudioService('init error: $e - retrying shared'); try { - await Future( - () => backend.initEngine(), - ).timeout(const Duration(seconds: 6)); - } catch (e2) { - Logger.errorAudioService('shared init failed: $e2'); - return; + print('[aqloss] initEngine attempt ${attempt + 1} start'); + if (deviceId != null) { + await backend + .initEngineWithDevice(deviceId: deviceId, exclusive: exclusive) + .timeout(const Duration(seconds: 8)); + } else { + await backend.initEngine().timeout(const Duration(seconds: 8)); + } + print('[aqloss] initEngine attempt ${attempt + 1} SUCCESS'); + _engineReady = true; + Logger.debugAudioService('engine ready (attempt ${attempt + 1})'); + break; + } catch (e, st) { + print('[aqloss] initEngine attempt ${attempt + 1} FAILED: $e'); + Logger.errorAudioService('init attempt ${attempt + 1} FAILED: $e\n$st'); + if (attempt == delays.length - 1) { + try { + print('[aqloss] initEngine fallback start'); + await backend.initEngine().timeout(const Duration(seconds: 6)); + print('[aqloss] initEngine fallback SUCCESS'); + _engineReady = true; + Logger.debugAudioService('engine ready (fallback shared)'); + } catch (e2, st2) { + print('[aqloss] initEngine FATAL: $e2'); + Logger.errorAudioService('engine init FATAL: $e2\n$st2'); + return; + } + } } } - _engineReady = true; + + if (!_engineReady) return; await _applyVolume(); if (settings != null) await applyAllDsp(settings); _startWatchdog(); @@ -86,12 +100,24 @@ class AudioService { // Playback static Future loadTrack(String path) async { + if (!_engineReady) { + for (int i = 0; i < 30; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + if (_engineReady) break; + } + } if (!_engineReady) throw Exception('AudioEngine not ready'); await backend.loadTrack(path: path); await _applyVolume(); } static Future play() async { + if (!_engineReady) { + for (int i = 0; i < 30; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + if (_engineReady) break; + } + } if (!_engineReady) throw Exception('AudioEngine not ready'); return backend.play(); } diff --git a/lib/util/android_path_helper.dart b/lib/util/android_path_helper.dart new file mode 100644 index 0000000..07b846a --- /dev/null +++ b/lib/util/android_path_helper.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; + +// Request storage read permission on Android. +Future requestAndroidStoragePermission() async { + if (!Platform.isAndroid) return true; + + final sdk = (await DeviceInfoPlugin().androidInfo).version.sdkInt; + + final permission = sdk >= 33 ? Permission.audio : Permission.storage; + + if (await permission.isGranted) return true; + final status = await permission.request(); + return status.isGranted; +} + +// Convert Android content +String resolveAndroidPath(String raw) { + if (!Platform.isAndroid) return raw; + if (!raw.startsWith('content://')) return raw; + + try { + final uri = Uri.parse(raw); + final segments = uri.pathSegments; + final encoded = segments.last; + final decoded = Uri.decodeComponent(encoded); + + if (decoded.contains(':')) { + final parts = decoded.split(':'); + final volume = parts[0]; + final relative = parts.length > 1 ? parts[1] : ''; + + final base = volume.toLowerCase() == 'primary' + ? '/storage/emulated/0' + : '/storage/$volume'; + + return relative.isEmpty ? base : '$base/$relative'; + } + } catch (_) {} + + return raw; +} diff --git a/lib/widgets/mini_player_bar.dart b/lib/widgets/mini_player_bar.dart index 39b38f3..5ebb671 100755 --- a/lib/widgets/mini_player_bar.dart +++ b/lib/widgets/mini_player_bar.dart @@ -546,12 +546,14 @@ class _MobileBar extends ConsumerWidget { activeColor: cs.onSurface.withValues(alpha: 0.36), inactiveColor: cs.onSurface.withValues(alpha: 0.08), onChanged: (v) { - if (duration.inMilliseconds > 0) + if (duration.inMilliseconds > 0) { notifier.seekPreview(duration * v); + } }, onChangeEnd: (v) { - if (duration.inMilliseconds > 0) + if (duration.inMilliseconds > 0) { notifier.seekCommit(duration * v); + } }, ), Padding( diff --git a/lib/widgets/spectrum_display.dart b/lib/widgets/spectrum_display.dart index 04d4d81..f0070ea 100755 --- a/lib/widgets/spectrum_display.dart +++ b/lib/widgets/spectrum_display.dart @@ -136,7 +136,10 @@ class _SpectrumDisplayState extends ConsumerState { 2.0, 8.0, ); - final gap = totalWidth / widget.barCount - barWidth; + final gap = (totalWidth / widget.barCount - barWidth).clamp( + 0.0, + double.infinity, + ); return Row( crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(widget.barCount, (i) { diff --git a/lib/widgets/track_grid_item.dart b/lib/widgets/track_grid_item.dart index 2a174bd..4ae7ea7 100644 --- a/lib/widgets/track_grid_item.dart +++ b/lib/widgets/track_grid_item.dart @@ -10,12 +10,14 @@ class TrackGridItem extends ConsumerStatefulWidget { final Track track; final VoidCallback? onTap; final VoidCallback? onLongPress; + final VoidCallback? onSecondaryTap; const TrackGridItem({ super.key, required this.track, this.onTap, this.onLongPress, + this.onSecondaryTap, }); @override @@ -56,6 +58,7 @@ class _TrackGridItemState extends ConsumerState { return GestureDetector( onTap: widget.onTap, onLongPress: widget.onLongPress, + onSecondaryTap: widget.onSecondaryTap, child: AnimatedContainer( duration: const Duration(milliseconds: 130), decoration: BoxDecoration( @@ -121,35 +124,39 @@ class _TrackGridItemState extends ConsumerState { ), ), ), - // Title + artist - Padding( - padding: const EdgeInsets.fromLTRB(7, 6, 7, 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.track.displayTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - fontWeight: isPlaying ? FontWeight.w500 : FontWeight.w400, - color: isPlaying - ? cs.onSurface - : cs.onSurface.withValues(alpha: 0.80), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(7, 6, 7, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.track.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + fontWeight: isPlaying + ? FontWeight.w500 + : FontWeight.w400, + color: isPlaying + ? cs.onSurface + : cs.onSurface.withValues(alpha: 0.80), + ), ), - ), - const SizedBox(height: 2), - Text( - widget.track.displayArtist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10, - color: cs.onSurface.withValues(alpha: 0.35), + const SizedBox(height: 2), + Text( + widget.track.displayArtist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + color: cs.onSurface.withValues(alpha: 0.35), + ), ), - ), - ], + ], + ), ), ), ], diff --git a/lib/widgets/track_tile.dart b/lib/widgets/track_tile.dart index 295b6f9..43fdec9 100755 --- a/lib/widgets/track_tile.dart +++ b/lib/widgets/track_tile.dart @@ -12,6 +12,7 @@ class TrackTile extends ConsumerWidget { final int? index; final VoidCallback? onTap; final VoidCallback? onLongPress; + final VoidCallback? onSecondaryTap; const TrackTile({ super.key, @@ -19,6 +20,7 @@ class TrackTile extends ConsumerWidget { this.index, this.onTap, this.onLongPress, + this.onSecondaryTap, }); @override @@ -83,6 +85,7 @@ class TrackTile extends ConsumerWidget { showBitDepth: showBitDepth, onTap: onTap, onLongPress: onLongPress, + onSecondaryTap: onSecondaryTap, ), ), child: _TileBody( @@ -93,6 +96,7 @@ class TrackTile extends ConsumerWidget { showBitDepth: showBitDepth, onTap: onTap, onLongPress: onLongPress, + onSecondaryTap: onSecondaryTap, ), ); } @@ -106,6 +110,7 @@ class _TileBody extends StatefulWidget { final bool showBitDepth; final VoidCallback? onTap; final VoidCallback? onLongPress; + final VoidCallback? onSecondaryTap; const _TileBody({ required this.track, @@ -115,6 +120,7 @@ class _TileBody extends StatefulWidget { this.index, this.onTap, this.onLongPress, + this.onSecondaryTap, }); @override @@ -133,6 +139,7 @@ class _TileBodyState extends State<_TileBody> { child: GestureDetector( onTap: widget.onTap, onLongPress: widget.onLongPress, + onSecondaryTap: widget.onSecondaryTap, behavior: HitTestBehavior.opaque, child: AnimatedContainer( duration: const Duration(milliseconds: 110), diff --git a/linux/xyz.nokarin.aqloss.metainfo.xml b/linux/xyz.nokarin.aqloss.metainfo.xml index 566141f..4113e8d 100755 --- a/linux/xyz.nokarin.aqloss.metainfo.xml +++ b/linux/xyz.nokarin.aqloss.metainfo.xml @@ -55,6 +55,7 @@

Added helpers, fix discord button label overflow, cursor pointer, seek throttle, and more.

    +
  • find button discord RPC now links to YouTube Music search
  • Call backend only on drag end to prevent seek throttle
  • All button now should has pointer now
  • Discord button label overflow
  • diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b9d8e71..6d36e3f 100755 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import audio_service import audio_session +import device_info_plus import file_picker import open_file_mac import screen_retriever_macos @@ -17,6 +18,7 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 77899b9..7b79501 100755 --- a/pubspec.lock +++ b/pubspec.lock @@ -272,6 +272,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65" + url: "https://pub.dev" + source: hosted + version: "13.1.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + url: "https://pub.dev" + source: hosted + version: "8.1.0" drift: dependency: transitive description: @@ -304,6 +320,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -316,10 +340,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + sha256: "51093fc49e4d58935998fb112593c23eda571d14b488388bd41d5d2ba332b26a" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "12.0.0-beta.3" fixnum: dependency: transitive description: @@ -750,6 +774,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -1263,10 +1335,18 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9 url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.2.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + url: "https://pub.dev" + source: hosted + version: "3.0.3" window_manager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 00e727e..db0d74e 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: json_annotation: ^4.9.0 # File picking - file_picker: ^11.0.2 + file_picker: ^12.0.0-beta.3 # Audio focus / media keys (mobile) audio_service: ^0.18.13 @@ -38,6 +38,8 @@ dependencies: http: ^1.6.0 crypto: ^3.0.7 open_file: ^3.5.11 + device_info_plus: ^13.1.0 + permission_handler: ^12.0.1 dev_dependencies: flutter_test: diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e516cbb..53938d8 100755 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -116,9 +116,11 @@ dependencies = [ "discord-presence", "flutter_rust_bridge", "image", + "jni", "lofty", "log", "lru", + "ndk-context", "ringbuf", "rubato", "symphonia", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 05ddab6..b812a60 100755 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -39,6 +39,10 @@ biquad = "0.6.0" image = "0.25.10" lru = "0.18.0" +[target.'cfg(target_os = "android")'.dependencies] +ndk-context = "0.1" +jni = { version = "0.21", default-features = false } + [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.62", features = [ "Win32_Media_Audio", diff --git a/rust/src/audio_engine.rs b/rust/src/audio_engine.rs index 6433082..a32ae5e 100755 --- a/rust/src/audio_engine.rs +++ b/rust/src/audio_engine.rs @@ -175,9 +175,20 @@ impl AudioEngine { eq: Arc::new(Mutex::new(Equalizer::new(sr, ch))), decode_thread_died: Arc::new(AtomicBool::new(false)), }; - ENGINE - .set(Arc::new(Mutex::new(engine))) - .map_err(|_| anyhow!("Already initialized")) + let arc = Arc::new(Mutex::new(engine)); + if ENGINE.set(arc.clone()).is_err() { + if let Some(existing) = ENGINE.get() { + let mut e = existing.lock().unwrap(); + let new_e = arc.lock().unwrap(); + unsafe { + let new_ptr = &*new_e as *const AudioEngine as *mut AudioEngine; + let old_ptr = &mut *e as *mut AudioEngine; + std::ptr::swap(old_ptr, new_ptr); + } + logger::info_audio("AudioEngine re-initialized in place"); + } + } + Ok(()) } // Accessors diff --git a/rust/src/discord_rpc.rs b/rust/src/discord_rpc.rs index a831e96..af0d1ea 100755 --- a/rust/src/discord_rpc.rs +++ b/rust/src/discord_rpc.rs @@ -104,6 +104,33 @@ pub fn update_playing( let find_artist = truncate(title, 27); + let base = "https://music.youtube.com/search?q="; + let query = format!("{title} {artist}"); + let encoded: String = query + .chars() + .map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), + ' ' => "+".to_string(), + c => c + .to_string() + .as_bytes() + .iter() + .map(|b| format!("%{b:02X}")) + .collect(), + }) + .collect(); + let max_encoded = 512 - base.len(); + let encoded = if encoded.len() > max_encoded { + let trimmed = &encoded[..max_encoded]; + match trimmed.rfind('%') { + Some(i) if i + 3 > max_encoded => trimmed[..i].to_string(), + _ => trimmed.to_string(), + } + } else { + encoded + }; + let ytm_query = format!("{base}{encoded}"); + let large_img = match album_art_url { Some(url) if !url.is_empty() && is_direct_image_url(url) => url, _ => "aqloss", @@ -123,11 +150,7 @@ pub fn update_playing( .state(&state_str) .details(&title_truncated) .timestamps(|t| t.start(start_ts).end(end_ts)) - .append_buttons(|button| { - button - .label(format!("Find {find_artist}")) - .url("https://google.com") - }) + .append_buttons(|button| button.label(format!("Find {find_artist}")).url(&ytm_query)) .append_buttons(|button| { button .label("Listen with Aqloss") diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 915889d..1dd81cc 100755 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -11,6 +11,24 @@ pub mod resampler; use flutter_rust_bridge::frb; +#[cfg(target_os = "android")] +#[no_mangle] +pub unsafe extern "C" fn Java_xyz_nokarin_aqloss_MainActivity_initAudioContext( + mut env: jni::JNIEnv, + _class: jni::objects::JClass, + ctx: jni::objects::JObject, +) { + let vm = env.get_java_vm().expect("initAudioContext: get_java_vm"); + let ctx_global = env + .new_global_ref(ctx) + .expect("initAudioContext: new_global_ref"); + ndk_context::initialize_android_context( + vm.get_java_vm_pointer().cast(), + ctx_global.as_raw().cast(), + ); + std::mem::forget(ctx_global); +} + #[frb(dart_metadata = ("freezed"))] pub struct TrackInfo { pub path: String, diff --git a/rust/src/logger.rs b/rust/src/logger.rs index 15bd121..0c6f62b 100644 --- a/rust/src/logger.rs +++ b/rust/src/logger.rs @@ -29,6 +29,7 @@ impl Level { // Internal state struct Logger { audio: Mutex, + output: Mutex, discord: Mutex, } @@ -78,6 +79,7 @@ pub fn init() { let logger = Logger { audio: Mutex::new(open_log(&dir, "audio.log")), + output: Mutex::new(open_log(&dir, "output.log")), discord: Mutex::new(open_log(&dir, "discord_rpc.log")), }; @@ -130,6 +132,7 @@ fn timestamp() -> String { // Core write enum Target { Audio, + Output, Discord, } @@ -150,6 +153,7 @@ fn write(target: Target, level: Level, msg: &str) { let file = match target { Target::Audio => &logger.audio, + Target::Output => &logger.output, Target::Discord => &logger.discord, }; @@ -206,8 +210,31 @@ pub fn error_discord(msg: impl AsRef) { #[macro_export] macro_rules! debug_discord { ($($arg:tt)*) => { $crate::logger::debug_discord(format!($($arg)*)) }; } #[macro_export] -macro_rules! info_discord { ($($arg:tt)*) => { $crate::logger::info_discord (format!($($arg)*)) }; } +macro_rules! info_discord { ($($arg:tt)*) => { $crate::logger::info_discord(format!($($arg)*)) }; } #[macro_export] -macro_rules! warn_discord { ($($arg:tt)*) => { $crate::logger::warn_discord (format!($($arg)*)) }; } +macro_rules! warn_discord { ($($arg:tt)*) => { $crate::logger::warn_discord(format!($($arg)*)) }; } #[macro_export] macro_rules! error_discord { ($($arg:tt)*) => { $crate::logger::error_discord(format!($($arg)*)) }; } + +// output +pub fn debug_output(msg: impl AsRef) { + write(Target::Output, Level::Debug, msg.as_ref()); +} +pub fn info_output(msg: impl AsRef) { + write(Target::Output, Level::Info, msg.as_ref()); +} +pub fn warn_output(msg: impl AsRef) { + write(Target::Output, Level::Warn, msg.as_ref()); +} +pub fn error_output(msg: impl AsRef) { + write(Target::Output, Level::Error, msg.as_ref()); +} + +#[macro_export] +macro_rules! debug_output { ($($arg:tt)*) => { $crate::logger::debug_output(format!($($arg)*)) }; } +#[macro_export] +macro_rules! info_output { ($($arg:tt)*) => { $crate::logger::info_output(format!($($arg)*)) }; } +#[macro_export] +macro_rules! warn_output { ($($arg:tt)*) => { $crate::logger::warn_output(format!($($arg)*)) }; } +#[macro_export] +macro_rules! error_output { ($($arg:tt)*) => { $crate::logger::error_output(format!($($arg)*)) }; } diff --git a/rust/src/output.rs b/rust/src/output.rs index adf2304..6b1f904 100755 --- a/rust/src/output.rs +++ b/rust/src/output.rs @@ -36,7 +36,9 @@ impl AudioOutput { if let Ok(exc) = wasapi_exclusive::ExclusiveStream::open_default() { return Ok(Self::from_exclusive(exc)); } - eprintln!("[aqloss] WASAPI exclusive not available on default device, using shared"); + crate::logger::warn_output( + "[aqloss] WASAPI exclusive not available on default device, using shared", + ); } Self::new_cpal_shared(None) } @@ -78,11 +80,13 @@ impl AudioOutput { }); match found { Some(d) => { - eprintln!("[aqloss] output device: {id}"); + crate::logger::info_output(format!("[aqloss] output device: {id}")); d } None => { - eprintln!("[aqloss] device '{id}' not found, using system default"); + crate::logger::warn_output(format!( + "[aqloss] device '{id}' not found, using system default" + )); host.default_output_device() .ok_or_else(|| anyhow!("No audio output device found"))? } @@ -93,13 +97,23 @@ impl AudioOutput { .ok_or_else(|| anyhow!("No audio output device found"))?, }; - let supported = device.default_output_config()?; + let supported = device.default_output_config().map_err(|e| { + crate::logger::error_output(format!("[aqloss] default_output_config failed: {e}")); + e + })?; let sample_rate: u32 = supported.sample_rate(); let channels: u32 = supported.channels() as u32; + crate::logger::info_output(format!( + "[aqloss] opening stream: {sample_rate}Hz {channels}ch" + )); + let config = cpal::StreamConfig { channels: supported.channels(), sample_rate: supported.sample_rate(), + #[cfg(target_os = "android")] + buffer_size: cpal::BufferSize::Default, + #[cfg(not(target_os = "android"))] buffer_size: cpal::BufferSize::Fixed(CPAL_BUFFER_FRAMES as u32), }; @@ -111,33 +125,41 @@ impl AudioOutput { let draining = Arc::new(AtomicBool::new(false)); let draining_cb = draining.clone(); - let stream = device.build_output_stream( - &config, - move |output: &mut [f32], _info| { - if draining_cb.load(Ordering::Relaxed) { - let avail = cons.occupied_len(); - let mut tmp = vec![0f32; avail]; - cons.pop_slice(&mut tmp); - output.fill(0.0); - } else { - let n = cons.occupied_len().min(output.len()); - cons.pop_slice(&mut output[..n]); - output[n..].fill(0.0); - } - }, - |err| eprintln!("[cpal] stream error: {err}"), - None, - )?; - - stream.play()?; - - eprintln!( + let stream = device + .build_output_stream( + &config, + move |output: &mut [f32], _info| { + if draining_cb.load(Ordering::Relaxed) { + let avail = cons.occupied_len(); + let mut tmp = vec![0f32; avail]; + cons.pop_slice(&mut tmp); + output.fill(0.0); + } else { + let n = cons.occupied_len().min(output.len()); + cons.pop_slice(&mut output[..n]); + output[n..].fill(0.0); + } + }, + |err| crate::logger::error_output(format!("[cpal] stream error: {err}")), + None, + ) + .map_err(|e| { + crate::logger::error_output(format!("[aqloss] build_output_stream failed: {e}")); + anyhow::anyhow!(e) + })?; + + stream.play().map_err(|e| { + crate::logger::error_output(format!("[aqloss] stream.play() failed: {e}")); + anyhow::anyhow!(e) + })?; + + crate::logger::info_output(format!( "[aqloss] shared-mode: {} @ {}Hz {}ch (buffer={} frames)", device_id.unwrap_or("default"), sample_rate, channels, CPAL_BUFFER_FRAMES - ); + )); Ok(Self { _stream: AudioStream::Cpal(stream), @@ -319,10 +341,10 @@ pub mod wasapi_exclusive { { chosen_sr = sr; chosen_ch = ch; - eprintln!( + crate::logger::info_output(format!( "[wasapi-exclusive] format: {}Hz {}ch f32 on device {}", sr, ch, device_id - ); + )); break; } } @@ -379,7 +401,10 @@ pub mod wasapi_exclusive { alive: Arc, draining: Arc, ) { - eprintln!("[wasapi-exclusive] audio thread started for {}", device_id); + crate::logger::info_output(format!( + "[wasapi-exclusive] audio thread started for {}", + device_id + )); unsafe { let _ = CoInitializeEx(None, COINIT_MULTITHREADED).ok(); @@ -387,7 +412,9 @@ pub mod wasapi_exclusive { let (audio_client, render_client, buffer_frames, event) = match result { Ok(r) => r, Err(e) => { - eprintln!("[wasapi-exclusive] thread setup failed: {e}"); + crate::logger::error_output(format!( + "[wasapi-exclusive] thread setup failed: {e}" + )); CoUninitialize(); return; } @@ -405,7 +432,7 @@ pub mod wasapi_exclusive { let buf_ptr = match render_client.GetBuffer(buffer_frames as u32) { Ok(p) => p, Err(e) => { - eprintln!("[wasapi-exclusive] GetBuffer: {e}"); + crate::logger::warn_output(format!("[wasapi-exclusive] GetBuffer: {e}")); break; } }; @@ -460,10 +487,10 @@ pub mod wasapi_exclusive { Err(ref e) if e.code().0 as u32 == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED => { let aligned_frames = audio_client.GetBufferSize()? as u64; let aligned_dur = (aligned_frames * 10_000_000) / sample_rate as u64; - eprintln!( + crate::logger::info_output(format!( "[wasapi-exclusive] alignment fix: {} frames", aligned_frames - ); + )); let wide2 = to_wide(device_id); let device2 = enumerator.GetDevice(PCWSTR::from_raw(wide2.as_ptr()))?; let ac2: IAudioClient = device2.Activate(CLSCTX_ALL, None)?; @@ -485,7 +512,9 @@ pub mod wasapi_exclusive { let render_client: IAudioRenderClient = audio_client.GetService()?; let buffer_frames = audio_client.GetBufferSize()? as usize; audio_client.Start()?; - eprintln!("[wasapi-exclusive] started, buffer_frames={buffer_frames}"); + crate::logger::info_output(format!( + "[wasapi-exclusive] started, buffer_frames={buffer_frames}" + )); Ok((audio_client, render_client, buffer_frames, event)) } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c6fe39a..d014922 100755 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b88734a..3b5d82b 100755 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows screen_retriever_windows window_manager )