diff --git a/.metadata b/.metadata index 98d77df..7a36f9b 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "ff37bef603469fb030f2b72995ab929ccfc227f0" + revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" channel: "stable" project_type: app @@ -13,17 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: android - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - platform: linux - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: windows - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 # User provided section diff --git a/README.md b/README.md index edfbd46..15ff7cf 100644 --- a/README.md +++ b/README.md @@ -101,4 +101,4 @@ ISCC.exe installer\Lunaris.iss - Приложение уважает API и rate limits сайтов. - NSFW/blur/blacklist настраиваются локально. - API keys/custom headers хранятся локально и экспортируются только через явный export settings. -- Локальные пути скачанных файлов не попадают в JSON export/import. +- Локальные пути скачанных файлов не попадают в JSON export/import. diff --git a/android/.gitignore b/android/.gitignore index be82512..a3124d9 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,11 +1,11 @@ /.gradle /captures/ /local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 55a5b1f..9ae91d0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,44 +1,44 @@ -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.example.gel_rule_app" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.gel_rule_app" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.gel_rule_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.gel_rule_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + flutter { source = "../.." } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..8ffe024 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,7 @@ - - - - + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index daf8498..956e680 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,32 +4,32 @@ - - - - - - - - - + android:name="${applicationName}" + android:icon="@mipmap/ic_launcher"> + + + + + + + + + @@ -43,15 +43,15 @@ android:resource="@xml/file_paths" /> - - - - - - - - + + + + + + + + diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index d3f4435..2a144ea 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,12 @@ - - - + + + - - - - + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index d3f4435..2a144ea 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,12 @@ - - - + + + - - - - + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..360a160 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -1,18 +1,18 @@ - - - - - - - + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 77c9133..503d363 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,17 +1,17 @@ - - - + + + - + + @drawable/launch_background + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 399f698..8ffe024 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -1,7 +1,7 @@ - - - - + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 6141170..7a89f0b 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -4,19 +4,19 @@ allprojects { repositories { google() mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} subprojects { project.evaluationDependsOn(":app") } diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..d5da727 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e4ef43f..db3f453 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/gradlew.bat b/android/gradlew.bat index 8a0b282..aec9973 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,90 +1,90 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca7fe06..4dcef4b 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,26 +1,26 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.20" apply false -} - -include(":app") +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 361f0f7..62404ad 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ const appDisplayVersion = '2.0.1'; -const appBuildNumber = 21; +const appBuildNumber = 22; const appReleaseChannel = 'Stable'; diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index a9c53df..cf8165d 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -32,7 +32,7 @@ class _FeedScreenState extends ConsumerState { final _scrollController = ScrollController(); final Set _selectedKeys = {}; bool _selectionMode = false; - bool _usedInitialQuery = false; + String? _appliedInitialQuery; Timer? _scrollSaveDebounce; @override @@ -70,10 +70,10 @@ class _FeedScreenState extends ConsumerState { if (nextQuery != null && nextQuery.isNotEmpty && nextQuery != oldWidget.initialQuery) { - _usedInitialQuery = true; + _appliedInitialQuery = nextQuery; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ref.read(feedControllerProvider.notifier).search(nextQuery); + _applySearchQuery(nextQuery); }); } } @@ -93,10 +93,16 @@ class _FeedScreenState extends ConsumerState { final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; final viewedKeys = ref.watch(viewedKeysProvider).value ?? {}; - if (!_usedInitialQuery && (widget.initialQuery?.isNotEmpty ?? false)) { - _usedInitialQuery = true; + final initialQuery = widget.initialQuery?.trim(); + final currentTags = feed.value?.selectedTags.join(' '); + if (initialQuery != null && + initialQuery.isNotEmpty && + _appliedInitialQuery != initialQuery && + currentTags != initialQuery) { + _appliedInitialQuery = initialQuery; WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(feedControllerProvider.notifier).search(widget.initialQuery!); + if (!mounted) return; + _applySearchQuery(initialQuery); }); } @@ -159,17 +165,14 @@ class _FeedScreenState extends ConsumerState { onSearchChanged: (query) => ref .read(feedControllerProvider.notifier) .updateTagSuggestions(query), - onSuggestionTap: (query) => - ref.read(feedControllerProvider.notifier).search(query), + onSuggestionTap: _submitSearch, onTopPeriodChanged: (period) => ref .read(feedControllerProvider.notifier) .setTopPeriod(period), - onSearch: (query) => - ref.read(feedControllerProvider.notifier).search(query), + onSearch: _submitSearch, onRefresh: () => ref.read(feedControllerProvider.notifier).refresh(), - onClearFilters: () => - ref.read(feedControllerProvider.notifier).clearFilters(), + onClearFilters: _clearFilters, selectionMode: _selectionMode, onToggleSelectionMode: () { setState(() { @@ -279,6 +282,35 @@ class _FeedScreenState extends ConsumerState { ); } + void _submitSearch(String query) { + final trimmed = query.trim(); + if (trimmed.isEmpty) { + _clearFilters(); + return; + } + final location = '/?q=${Uri.encodeQueryComponent(trimmed)}'; + if (widget.initialQuery?.trim() == trimmed) { + _applySearchQuery(trimmed); + return; + } + context.go(location); + } + + Future _applySearchQuery(String query) async { + await ref.read(feedControllerProvider.notifier).search(query); + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + } + + Future _clearFilters() async { + _appliedInitialQuery = null; + await ref.read(feedControllerProvider.notifier).clearFilters(); + if (mounted && widget.initialQuery != null) { + context.go('/'); + } + } + void _toggleSelected(Post post) { setState(() { _selectionMode = true; diff --git a/lib/features/post/presentation/post_details_controller.dart b/lib/features/post/presentation/post_details_controller.dart index 4b5768f..bc65ba5 100644 --- a/lib/features/post/presentation/post_details_controller.dart +++ b/lib/features/post/presentation/post_details_controller.dart @@ -3,33 +3,57 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../backend/backend.dart'; import '../../../core/utils/result.dart'; +// Меняем FutureProvider на StreamProvider final postDetailsControllerProvider = - FutureProvider.family((ref, args) async { + StreamProvider.family((ref, args) async* { + // Правильно собираем зависимости на самом верхнем уровне (фиксим антипаттерн) + final providerManager = ref.watch(providerManagerProvider); + final cacheService = ref.watch(cacheServiceProvider); + final postRepository = ref.watch(postRepositoryProvider); + + // Вспомогательная функция для загрузки тегов Future enrich(Post? post) async { if (post == null) return null; final shouldEnrich = post.tagGroups.isEmpty || (post.tagGroups.length == 1 && post.tagGroups.containsKey('general')); if (!shouldEnrich) return post; - final enriched = - await ref.watch(providerManagerProvider).enrichPostTags(post); + + final enriched = await providerManager.enrichPostTags(post); if (enriched is Success) { - await ref.watch(cacheServiceProvider).cachePosts([enriched.data]); + await cacheService.cachePosts([enriched.data]); return enriched.data; } return post; } - if (args.initialPost != null) return enrich(args.initialPost); - final cached = await ref - .watch(postRepositoryProvider) - .getCachedPost(args.postId, args.providerId); + // СЦЕНАРИЙ 1: Пост уже передан из ленты (Самый частый случай) + if (args.initialPost != null) { + yield args + .initialPost; // МГНОВЕННО отдаем пост в UI, экран сразу рендерится! + final enriched = await enrich(args.initialPost); + yield enriched; // Чуть позже отдаем версию с загруженными тегами + return; + } + + // СЦЕНАРИЙ 2: Прямой переход (например, по ссылке), проверяем локальный кэш + final cached = + await postRepository.getCachedPost(args.postId, args.providerId); if (cached is Success && cached.data != null) { - return enrich(cached.data); + yield cached.data; // Нашли в кэше — сразу показываем контент + final enriched = await enrich(cached.data); + yield enriched; // Догружаем теги на фоне + return; + } + + // СЦЕНАРИЙ 3: Полный фоллбэк, если вообще ничего нет — идем в сеть за самим постом + final remote = await providerManager.getPost(args.providerId, args.postId); + if (remote is Success && remote.data != null) { + yield remote.data; + final enriched = await enrich(remote.data); + yield enriched; + } else { + yield null; } - final remote = await ref - .watch(providerManagerProvider) - .getPost(args.providerId, args.postId); - return remote is Success ? enrich(remote.data) : null; }); class PostDetailsArgs { diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 9b7b56e..3bc2e5f 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -1,872 +1,866 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../../../app/app.dart'; -import '../../../app/app_strings.dart'; -import '../../../app/motion.dart'; -import '../../../app/responsive.dart'; -import '../../../backend/backend.dart'; -import '../../../core/utils/result.dart'; -import '../../../shared/widgets/adaptive_scaffold.dart'; -import '../../../shared/widgets/empty_view.dart'; -import '../../../shared/widgets/error_view.dart'; -import '../../../shared/widgets/post_card.dart'; -import '../../../shared/widgets/rating_badge.dart'; -import '../../collections/presentation/collection_form_dialog.dart'; -import '../../favorites/presentation/favorites_controller.dart'; -import '../../feed/presentation/feed_controller.dart'; -import '../../viewed/presentation/viewed_controller.dart'; -import 'post_details_controller.dart'; -import 'widgets/post_action_bar.dart'; -import 'widgets/post_media_viewer.dart'; -import 'widgets/post_tags_panel.dart'; - -final postCommentsProvider = - FutureProvider.family, PostDetailsArgs>( - (ref, args) async { - final result = await ref - .watch(providerManagerProvider) - .getComments(args.providerId, args.postId); - return result is Success> ? result.data : const []; -}); - -class PostDetailsScreen extends ConsumerWidget { - const PostDetailsScreen({ - required this.providerId, - required this.postId, - this.initialPost, - super.key, - }); - - final String providerId; - final String postId; - final Post? initialPost; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final args = PostDetailsArgs( - providerId: providerId, - postId: postId, - initialPost: initialPost, - ); - final post = ref.watch(postDetailsControllerProvider(args)); - final settings = - ref.watch(appSettingsProvider).value ?? AppSettings.defaults; - final strings = ref.watch(appStringsProvider); - final feedPosts = - ref.watch(feedControllerProvider).value?.posts ?? const []; - final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; - return AdaptiveScaffold( - title: strings.post, - actions: [ - IconButton( - tooltip: strings.close, - onPressed: () => _close(context), - icon: const Icon(Icons.close_rounded), - ), - ], - body: post.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => ErrorView(message: error.toString()), - data: (post) { - if (post == null) return const EmptyView(title: 'Post not found'); - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(viewedHistoryServiceProvider).markViewed(post); - ref.invalidate(viewedKeysProvider); - ref.invalidate(viewedControllerProvider); - }); - final currentIndex = feedPosts.indexWhere( - (item) => item.providerId == post.providerId && item.id == post.id, - ); - final previous = - currentIndex > 0 ? feedPosts[currentIndex - 1] : null; - final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1 - ? feedPosts[currentIndex + 1] - : null; - final qualityMode = - MediaQualityMode.fromName(settings.mediaQualityMode); - if (Responsive.isMobile(context)) { - if (currentIndex >= 0 && feedPosts.length > 1) { - return _MobilePostPager( - posts: feedPosts, - initialIndex: currentIndex, - buildDetails: (context, post) => _buildMobileDetails( - context, - ref, - post, - settings, - favoriteKeys, - qualityMode, - strings, - ), - ); - } - return _buildMobileDetails( - context, - ref, - post, - settings, - favoriteKeys, - qualityMode, - strings, - ); - } - return Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.arrowLeft): - const _PreviousPostIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowRight): - const _NextPostIntent(), - LogicalKeySet(LogicalKeyboardKey.keyF): - const _ToggleFavoriteIntent(), - LogicalKeySet(LogicalKeyboardKey.keyC): - const _AddCollectionIntent(), - LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(), - LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(), - }, - child: Actions( - actions: { - _PreviousPostIntent: CallbackAction<_PreviousPostIntent>( - onInvoke: (_) { - if (previous != null) _openPost(context, previous); - return null; - }, - ), - _NextPostIntent: CallbackAction<_NextPostIntent>( - onInvoke: (_) { - if (next != null) _openPost(context, next); - return null; - }, - ), - _ToggleFavoriteIntent: CallbackAction<_ToggleFavoriteIntent>( - onInvoke: (_) { - _toggleFavorite(ref, post, favoriteKeys); - return null; - }, - ), - _AddCollectionIntent: CallbackAction<_AddCollectionIntent>( - onInvoke: (_) { - _addToCollection(context, ref, post); - return null; - }, - ), - _CloseIntent: CallbackAction<_CloseIntent>( - onInvoke: (_) { - _close(context); - return null; - }, - ), - _DownloadIntent: CallbackAction<_DownloadIntent>( - onInvoke: (_) { - if (settings.allowDownloads) { - _download(context, ref, post); - } - return null; - }, - ), - }, - child: Focus( - autofocus: true, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton.filledTonal( - tooltip: - strings.ru ? 'Предыдущий' : 'Previous', - onPressed: previous == null - ? null - : () => _openPost(context, previous), - icon: const Icon(Icons.chevron_left_rounded), - ), - Expanded( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 760), - child: PostMediaViewer( - key: ValueKey(post.cacheKey), - post: post, - qualityMode: qualityMode, - initialLoop: settings.videoPlayerLoop, - initialMuted: settings.videoPlayerMuted, - initialCoverVideo: settings.videoPlayerCover, - initialHalfVolume: - settings.videoPlayerHalfVolume, - onPlaybackPreferencesChanged: (snapshot) => - _saveVideoPreferences(ref, snapshot), - ), - ), - ), - ), - IconButton.filledTonal( - tooltip: strings.ru ? 'Следующий' : 'Next', - onPressed: next == null - ? null - : () => _openPost(context, next), - icon: const Icon(Icons.chevron_right_rounded), - ), - ], - ), - if (currentIndex >= 0) ...[ - const SizedBox(height: 10), - _NeighborStrip( - posts: feedPosts, - currentIndex: currentIndex, - onOpen: (post) => _openPost(context, post), - ), - ], - const SizedBox(height: 16), - PostActionBar( - isFavorite: favoriteKeys.contains(post.cacheKey), - labels: _postActionLabels(strings), - downloaded: ref - .watch( - downloadedMediaByKeyProvider(post.cacheKey)) - .value != - null, - onFavorite: () => - _toggleFavorite(ref, post, favoriteKeys), - onCollection: () => _addToCollection(context, ref, post), - onOpen: () => launchUrl(Uri.parse(post.fileUrl)), - onCopy: () => - Clipboard.setData(ClipboardData(text: post.fileUrl)), - onSimilar: () => _openSimilar(context, ref, post), - onHide: () => _hidePost(context, ref, post), - onDownload: settings.allowDownloads - ? () => _download(context, ref, post) - : null, - onDeleteLocalFile: () => - _deleteLocalFile(context, ref, post), - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Chip(label: Text(post.providerName)), - RatingBadge(rating: post.rating), - Chip(label: Text('${post.width} x ${post.height}')), - if (post.source != null && post.source!.isNotEmpty) - ActionChip( - label: Text(strings.source), - onPressed: () => launchUrl(Uri.parse(post.source!)), - ), - ], - ), - const SizedBox(height: 16), - Text(strings.tags, - style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - PostTagsPanel(post: post), - const SizedBox(height: 16), - _CommentsSection(post: post), - ], - ), - ), - ), - ); - }, - ), - ); - } - - Widget _buildMobileDetails( - BuildContext context, - WidgetRef ref, - Post post, - AppSettings settings, - Set favoriteKeys, - MediaQualityMode qualityMode, - AppStrings strings, - ) { - final isVideo = MediaUrlSelector.isVideo(post); - return GestureDetector( - behavior: HitTestBehavior.translucent, - onVerticalDragEnd: (details) { - if ((details.primaryVelocity ?? 0) > 900) _close(context); - }, - child: ListView( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), - children: [ - GestureDetector( - behavior: HitTestBehavior.deferToChild, - onDoubleTap: - isVideo ? null : () => _toggleFavorite(ref, post, favoriteKeys), - onLongPress: isVideo - ? null - : () => - _showMobileQuickActions(context, ref, post, favoriteKeys), - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(context).height * 0.62, - ), - child: Center( - child: PostMediaViewer( - key: ValueKey(post.cacheKey), - post: post, - qualityMode: qualityMode, - initialLoop: settings.videoPlayerLoop, - initialMuted: settings.videoPlayerMuted, - initialCoverVideo: settings.videoPlayerCover, - initialHalfVolume: settings.videoPlayerHalfVolume, - onPlaybackPreferencesChanged: (snapshot) => - _saveVideoPreferences(ref, snapshot), - ), - ), - ), - ), - const SizedBox(height: 12), - PostActionBar( - isFavorite: favoriteKeys.contains(post.cacheKey), - labels: _postActionLabels(strings), - downloaded: - ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value != - null, - onFavorite: () => _toggleFavorite(ref, post, favoriteKeys), - onCollection: () => _addToCollection(context, ref, post), - onOpen: () => launchUrl(Uri.parse(post.fileUrl)), - onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)), - onSimilar: () => _openSimilar(context, ref, post), - onHide: () => _hidePost(context, ref, post), - onDownload: settings.allowDownloads - ? () => _download(context, ref, post) - : null, - onDeleteLocalFile: () => _deleteLocalFile(context, ref, post), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - Chip(label: Text(post.providerName)), - RatingBadge(rating: post.rating), - Chip(label: Text('${post.width} x ${post.height}')), - if (post.source != null && post.source!.isNotEmpty) - ActionChip( - label: Text(strings.source), - onPressed: () => launchUrl(Uri.parse(post.source!)), - ), - ], - ), - const SizedBox(height: 8), - ExpansionTile( - tilePadding: EdgeInsets.zero, - initiallyExpanded: false, - title: Text(strings.tags, - style: Theme.of(context).textTheme.titleMedium), - children: [ - Align( - alignment: Alignment.centerLeft, - child: PostTagsPanel(post: post), - ), - ], - ), - _CommentsSection(post: post), - ], - ), - ); - } - - Future _addToCollection( - BuildContext context, - WidgetRef ref, - Post post, - ) async { - final result = await ref.read(collectionServiceProvider).getCollections(); - final collections = - result is Success> ? result.data : []; - if (!context.mounted) return; - await showAddToCollectionPicker( - context, - collections: collections, - onSelected: (collection) { - ref - .read(collectionServiceProvider) - .addPostToCollection(collection.id, post); - }, - onCreate: () => showCollectionFormDialog(context, ref), - ); - } - - Future _download(BuildContext context, WidgetRef ref, Post post) async { - if (!context.mounted) return; - await ref.read(downloadManagerServiceProvider).start(post); - if (!context.mounted) return; - final strings = ref.read(appStringsProvider); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(strings.ru - ? 'Скачивание началось' - : 'Download started'), - ), - ); - Future.delayed(const Duration(seconds: 2), () { - ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey)); - }); - } - - Future _showMobileQuickActions( - BuildContext context, - WidgetRef ref, - Post post, - Set favoriteKeys, - ) async { - await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon( - favoriteKeys.contains(post.cacheKey) - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - ), - title: Text( - favoriteKeys.contains(post.cacheKey) - ? 'Remove favorite' - : 'Favorite', - ), - onTap: () async { - Navigator.pop(sheetContext); - await _toggleFavorite(ref, post, favoriteKeys); - }, - ), - ListTile( - leading: const Icon(Icons.collections_bookmark_rounded), - title: const Text('Add to collection'), - onTap: () { - Navigator.pop(sheetContext); - _addToCollection(context, ref, post); - }, - ), - ListTile( - leading: const Icon(Icons.copy_rounded), - title: const Text('Copy link'), - onTap: () { - Clipboard.setData(ClipboardData(text: post.fileUrl)); - Navigator.pop(sheetContext); - }, - ), - ListTile( - leading: const Icon(Icons.download_rounded), - title: const Text('Download'), - onTap: () { - Navigator.pop(sheetContext); - _download(context, ref, post); - }, - ), - ], - ), - ), - ); - } - - Future _toggleFavorite( - WidgetRef ref, - Post post, - Set favoriteKeys, - ) async { - if (favoriteKeys.contains(post.cacheKey)) { - await ref - .read(favoriteServiceProvider) - .removeFavorite(post.id, post.providerId); - } else { - await ref.read(favoriteServiceProvider).addFavorite(post); - await _maybeAutoDownloadFavorite(ref, post); - } - ref.invalidate(favoriteKeysProvider); - ref.invalidate(favoritesControllerProvider); - } - - Future _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async { - final settings = - ref.read(appSettingsProvider).value ?? AppSettings.defaults; - if (!settings.autoDownloadFavorites || !settings.allowDownloads) return; - await ref.read(downloadManagerServiceProvider).start(post); - } - - Future _deleteLocalFile( - BuildContext context, - WidgetRef ref, - Post post, - ) async { - final strings = ref.read(appStringsProvider); - await ref.read(downloadedMediaServiceProvider).deleteLocalFile( - post.cacheKey, - ); - ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey)); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(strings.deleteLocalFile)), - ); - } - - PostActionLabels _postActionLabels(AppStrings strings) { - return PostActionLabels( - favorite: strings.favorite, - unfavorite: strings.removeFavorite, - collection: strings.collection, - similar: strings.similar, - openOriginal: strings.open, - copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link', - download: strings.download, - deleteLocalFile: strings.deleteLocalFile, - hidePost: strings.hidePost, - ); - } - - Future _hidePost( - BuildContext context, - WidgetRef ref, - Post post, - ) async { - await ref.read(settingsServiceProvider).hidePostKey(post.cacheKey); - ref.invalidate(appSettingsProvider); - ref.read(feedControllerProvider.notifier).refresh(); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Post hidden locally')), - ); - _close(context); - } - - void _openPost(BuildContext context, Post post) { - _replacePost(context, post); - } - - void _openSimilar(BuildContext context, WidgetRef ref, Post post) { - context.push('/post/${post.providerId}/${post.id}/similar', extra: post); - } - - void _replacePost(BuildContext context, Post post) { - context.replace('/post/${post.providerId}/${post.id}', extra: post); - } - - void _close(BuildContext context) { - if (Navigator.of(context).canPop()) { - context.pop(); - return; - } - context.go('/'); - } - - Future _saveVideoPreferences( - WidgetRef ref, - VideoPlaybackSnapshot snapshot, - ) async { - final result = await ref.read(settingsServiceProvider).getSettings(); - if (result is! Success) return; - final settings = result.data; - if (settings.videoPlayerMuted == snapshot.muted && - settings.videoPlayerHalfVolume == snapshot.halfVolume && - settings.videoPlayerLoop == snapshot.loopVideo && - settings.videoPlayerCover == snapshot.coverVideo) { - return; - } - await ref.read(settingsServiceProvider).updateSettings( - settings.copyWith( - videoPlayerMuted: snapshot.muted, - videoPlayerHalfVolume: snapshot.halfVolume, - videoPlayerLoop: snapshot.loopVideo, - videoPlayerCover: snapshot.coverVideo, - ), - ); - ref.invalidate(appSettingsProvider); - } -} - -class _MobilePostPager extends StatefulWidget { - const _MobilePostPager({ - required this.posts, - required this.initialIndex, - required this.buildDetails, - }); - - final List posts; - final int initialIndex; - final Widget Function(BuildContext context, Post post) buildDetails; - - @override - State<_MobilePostPager> createState() => _MobilePostPagerState(); -} - +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../app/app.dart'; +import '../../../app/app_strings.dart'; +import '../../../app/motion.dart'; +import '../../../app/responsive.dart'; +import '../../../backend/backend.dart'; +import '../../../core/utils/result.dart'; +import '../../../shared/widgets/adaptive_scaffold.dart'; +import '../../../shared/widgets/empty_view.dart'; +import '../../../shared/widgets/error_view.dart'; +import '../../../shared/widgets/post_card.dart'; +import '../../../shared/widgets/rating_badge.dart'; +import '../../collections/presentation/collection_form_dialog.dart'; +import '../../favorites/presentation/favorites_controller.dart'; +import '../../feed/presentation/feed_controller.dart'; +import '../../viewed/presentation/viewed_controller.dart'; +import 'post_details_controller.dart'; +import 'widgets/post_action_bar.dart'; +import 'widgets/post_media_viewer.dart'; +import 'widgets/post_tags_panel.dart'; + +final postCommentsProvider = + FutureProvider.family, PostDetailsArgs>( + (ref, args) async { + final result = await ref + .watch(providerManagerProvider) + .getComments(args.providerId, args.postId); + return result is Success> ? result.data : const []; +}); + +class PostDetailsScreen extends ConsumerWidget { + const PostDetailsScreen({ + required this.providerId, + required this.postId, + this.initialPost, + super.key, + }); + + final String providerId; + final String postId; + final Post? initialPost; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final args = PostDetailsArgs( + providerId: providerId, + postId: postId, + initialPost: initialPost, + ); + final post = ref.watch(postDetailsControllerProvider(args)); + final settings = + ref.watch(appSettingsProvider).value ?? AppSettings.defaults; + final strings = ref.watch(appStringsProvider); + final feedPosts = + ref.watch(feedControllerProvider).value?.posts ?? const []; + final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; + return AdaptiveScaffold( + title: strings.post, + actions: [ + IconButton( + tooltip: strings.close, + onPressed: () => _close(context), + icon: const Icon(Icons.close_rounded), + ), + ], + body: post.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => ErrorView(message: error.toString()), + data: (post) { + if (post == null) return const EmptyView(title: 'Post not found'); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(viewedHistoryServiceProvider).markViewed(post); + ref.invalidate(viewedKeysProvider); + ref.invalidate(viewedControllerProvider); + }); + final currentIndex = feedPosts.indexWhere( + (item) => item.providerId == post.providerId && item.id == post.id, + ); + final previous = + currentIndex > 0 ? feedPosts[currentIndex - 1] : null; + final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1 + ? feedPosts[currentIndex + 1] + : null; + final qualityMode = + MediaQualityMode.fromName(settings.mediaQualityMode); + if (Responsive.isMobile(context)) { + if (currentIndex >= 0 && feedPosts.length > 1) { + return _MobilePostPager( + posts: feedPosts, + initialIndex: currentIndex, + buildDetails: (context, post) => _buildMobileDetails( + context, + ref, + post, + settings, + favoriteKeys, + qualityMode, + strings, + ), + ); + } + return _buildMobileDetails( + context, + ref, + post, + settings, + favoriteKeys, + qualityMode, + strings, + ); + } + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.arrowLeft): + const _PreviousPostIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): + const _NextPostIntent(), + LogicalKeySet(LogicalKeyboardKey.keyF): + const _ToggleFavoriteIntent(), + LogicalKeySet(LogicalKeyboardKey.keyC): + const _AddCollectionIntent(), + LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(), + LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(), + }, + child: Actions( + actions: { + _PreviousPostIntent: CallbackAction<_PreviousPostIntent>( + onInvoke: (_) { + if (previous != null) _openPost(context, previous); + return null; + }, + ), + _NextPostIntent: CallbackAction<_NextPostIntent>( + onInvoke: (_) { + if (next != null) _openPost(context, next); + return null; + }, + ), + _ToggleFavoriteIntent: CallbackAction<_ToggleFavoriteIntent>( + onInvoke: (_) { + _toggleFavorite(ref, post, favoriteKeys); + return null; + }, + ), + _AddCollectionIntent: CallbackAction<_AddCollectionIntent>( + onInvoke: (_) { + _addToCollection(context, ref, post); + return null; + }, + ), + _CloseIntent: CallbackAction<_CloseIntent>( + onInvoke: (_) { + _close(context); + return null; + }, + ), + _DownloadIntent: CallbackAction<_DownloadIntent>( + onInvoke: (_) { + if (settings.allowDownloads) { + _download(context, ref, post); + } + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton.filledTonal( + tooltip: strings.ru ? 'Предыдущий' : 'Previous', + onPressed: previous == null + ? null + : () => _openPost(context, previous), + icon: const Icon(Icons.chevron_left_rounded), + ), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 760), + child: PostMediaViewer( + key: ValueKey(post.cacheKey), + post: post, + qualityMode: qualityMode, + initialLoop: settings.videoPlayerLoop, + initialMuted: settings.videoPlayerMuted, + initialCoverVideo: settings.videoPlayerCover, + initialHalfVolume: + settings.videoPlayerHalfVolume, + onPlaybackPreferencesChanged: (snapshot) => + _saveVideoPreferences(ref, snapshot), + ), + ), + ), + ), + IconButton.filledTonal( + tooltip: strings.ru ? 'Следующий' : 'Next', + onPressed: next == null + ? null + : () => _openPost(context, next), + icon: const Icon(Icons.chevron_right_rounded), + ), + ], + ), + if (currentIndex >= 0) ...[ + const SizedBox(height: 10), + _NeighborStrip( + posts: feedPosts, + currentIndex: currentIndex, + onOpen: (post) => _openPost(context, post), + ), + ], + const SizedBox(height: 16), + PostActionBar( + isFavorite: favoriteKeys.contains(post.cacheKey), + labels: _postActionLabels(strings), + downloaded: ref + .watch( + downloadedMediaByKeyProvider(post.cacheKey)) + .value != + null, + onFavorite: () => + _toggleFavorite(ref, post, favoriteKeys), + onCollection: () => _addToCollection(context, ref, post), + onOpen: () => launchUrl(Uri.parse(post.fileUrl)), + onCopy: () => + Clipboard.setData(ClipboardData(text: post.fileUrl)), + onSimilar: () => _openSimilar(context, ref, post), + onHide: () => _hidePost(context, ref, post), + onDownload: settings.allowDownloads + ? () => _download(context, ref, post) + : null, + onDeleteLocalFile: () => + _deleteLocalFile(context, ref, post), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Chip(label: Text(post.providerName)), + RatingBadge(rating: post.rating), + Chip(label: Text('${post.width} x ${post.height}')), + if (post.source != null && post.source!.isNotEmpty) + ActionChip( + label: Text(strings.source), + onPressed: () => launchUrl(Uri.parse(post.source!)), + ), + ], + ), + const SizedBox(height: 16), + Text(strings.tags, + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + PostTagsPanel(post: post), + const SizedBox(height: 16), + _CommentsSection(post: post), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildMobileDetails( + BuildContext context, + WidgetRef ref, + Post post, + AppSettings settings, + Set favoriteKeys, + MediaQualityMode qualityMode, + AppStrings strings, + ) { + final isVideo = MediaUrlSelector.isVideo(post); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragEnd: (details) { + if ((details.primaryVelocity ?? 0) > 900) _close(context); + }, + child: ListView( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), + children: [ + GestureDetector( + behavior: HitTestBehavior.deferToChild, + onDoubleTap: + isVideo ? null : () => _toggleFavorite(ref, post, favoriteKeys), + onLongPress: isVideo + ? null + : () => + _showMobileQuickActions(context, ref, post, favoriteKeys), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.62, + ), + child: Center( + child: PostMediaViewer( + key: ValueKey(post.cacheKey), + post: post, + qualityMode: qualityMode, + initialLoop: settings.videoPlayerLoop, + initialMuted: settings.videoPlayerMuted, + initialCoverVideo: settings.videoPlayerCover, + initialHalfVolume: settings.videoPlayerHalfVolume, + onPlaybackPreferencesChanged: (snapshot) => + _saveVideoPreferences(ref, snapshot), + ), + ), + ), + ), + const SizedBox(height: 12), + PostActionBar( + isFavorite: favoriteKeys.contains(post.cacheKey), + labels: _postActionLabels(strings), + downloaded: + ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value != + null, + onFavorite: () => _toggleFavorite(ref, post, favoriteKeys), + onCollection: () => _addToCollection(context, ref, post), + onOpen: () => launchUrl(Uri.parse(post.fileUrl)), + onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)), + onSimilar: () => _openSimilar(context, ref, post), + onHide: () => _hidePost(context, ref, post), + onDownload: settings.allowDownloads + ? () => _download(context, ref, post) + : null, + onDeleteLocalFile: () => _deleteLocalFile(context, ref, post), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text(post.providerName)), + RatingBadge(rating: post.rating), + Chip(label: Text('${post.width} x ${post.height}')), + if (post.source != null && post.source!.isNotEmpty) + ActionChip( + label: Text(strings.source), + onPressed: () => launchUrl(Uri.parse(post.source!)), + ), + ], + ), + const SizedBox(height: 8), + ExpansionTile( + tilePadding: EdgeInsets.zero, + initiallyExpanded: false, + title: Text(strings.tags, + style: Theme.of(context).textTheme.titleMedium), + children: [ + Align( + alignment: Alignment.centerLeft, + child: PostTagsPanel(post: post), + ), + ], + ), + _CommentsSection(post: post), + ], + ), + ); + } + + Future _addToCollection( + BuildContext context, + WidgetRef ref, + Post post, + ) async { + final result = await ref.read(collectionServiceProvider).getCollections(); + final collections = + result is Success> ? result.data : []; + if (!context.mounted) return; + await showAddToCollectionPicker( + context, + collections: collections, + onSelected: (collection) { + ref + .read(collectionServiceProvider) + .addPostToCollection(collection.id, post); + }, + onCreate: () => showCollectionFormDialog(context, ref), + ); + } + + Future _download(BuildContext context, WidgetRef ref, Post post) async { + if (!context.mounted) return; + await ref.read(downloadManagerServiceProvider).start(post); + if (!context.mounted) return; + final strings = ref.read(appStringsProvider); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(strings.ru ? 'Скачивание началось' : 'Download started'), + ), + ); + Future.delayed(const Duration(seconds: 2), () { + ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey)); + }); + } + + Future _showMobileQuickActions( + BuildContext context, + WidgetRef ref, + Post post, + Set favoriteKeys, + ) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + favoriteKeys.contains(post.cacheKey) + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + ), + title: Text( + favoriteKeys.contains(post.cacheKey) + ? 'Remove favorite' + : 'Favorite', + ), + onTap: () async { + Navigator.pop(sheetContext); + await _toggleFavorite(ref, post, favoriteKeys); + }, + ), + ListTile( + leading: const Icon(Icons.collections_bookmark_rounded), + title: const Text('Add to collection'), + onTap: () { + Navigator.pop(sheetContext); + _addToCollection(context, ref, post); + }, + ), + ListTile( + leading: const Icon(Icons.copy_rounded), + title: const Text('Copy link'), + onTap: () { + Clipboard.setData(ClipboardData(text: post.fileUrl)); + Navigator.pop(sheetContext); + }, + ), + ListTile( + leading: const Icon(Icons.download_rounded), + title: const Text('Download'), + onTap: () { + Navigator.pop(sheetContext); + _download(context, ref, post); + }, + ), + ], + ), + ), + ); + } + + Future _toggleFavorite( + WidgetRef ref, + Post post, + Set favoriteKeys, + ) async { + if (favoriteKeys.contains(post.cacheKey)) { + await ref + .read(favoriteServiceProvider) + .removeFavorite(post.id, post.providerId); + } else { + await ref.read(favoriteServiceProvider).addFavorite(post); + await _maybeAutoDownloadFavorite(ref, post); + } + ref.invalidate(favoriteKeysProvider); + ref.invalidate(favoritesControllerProvider); + } + + Future _maybeAutoDownloadFavorite(WidgetRef ref, Post post) async { + final settings = + ref.read(appSettingsProvider).value ?? AppSettings.defaults; + if (!settings.autoDownloadFavorites || !settings.allowDownloads) return; + await ref.read(downloadManagerServiceProvider).start(post); + } + + Future _deleteLocalFile( + BuildContext context, + WidgetRef ref, + Post post, + ) async { + final strings = ref.read(appStringsProvider); + await ref.read(downloadedMediaServiceProvider).deleteLocalFile( + post.cacheKey, + ); + ref.invalidate(downloadedMediaByKeyProvider(post.cacheKey)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(strings.deleteLocalFile)), + ); + } + + PostActionLabels _postActionLabels(AppStrings strings) { + return PostActionLabels( + favorite: strings.favorite, + unfavorite: strings.removeFavorite, + collection: strings.collection, + similar: strings.similar, + openOriginal: strings.open, + copyLink: strings.ru ? 'Копировать ссылку' : 'Copy link', + download: strings.download, + deleteLocalFile: strings.deleteLocalFile, + hidePost: strings.hidePost, + ); + } + + Future _hidePost( + BuildContext context, + WidgetRef ref, + Post post, + ) async { + await ref.read(settingsServiceProvider).hidePostKey(post.cacheKey); + ref.invalidate(appSettingsProvider); + ref.read(feedControllerProvider.notifier).refresh(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Post hidden locally')), + ); + _close(context); + } + + void _openPost(BuildContext context, Post post) { + _replacePost(context, post); + } + + void _openSimilar(BuildContext context, WidgetRef ref, Post post) { + context.push('/post/${post.providerId}/${post.id}/similar', extra: post); + } + + void _replacePost(BuildContext context, Post post) { + context.replace('/post/${post.providerId}/${post.id}', extra: post); + } + + void _close(BuildContext context) { + if (Navigator.of(context).canPop()) { + context.pop(); + return; + } + context.go('/'); + } + + Future _saveVideoPreferences( + WidgetRef ref, + VideoPlaybackSnapshot snapshot, + ) async { + final result = await ref.read(settingsServiceProvider).getSettings(); + if (result is! Success) return; + final settings = result.data; + if (settings.videoPlayerMuted == snapshot.muted && + settings.videoPlayerHalfVolume == snapshot.halfVolume && + settings.videoPlayerLoop == snapshot.loopVideo && + settings.videoPlayerCover == snapshot.coverVideo) { + return; + } + await ref.read(settingsServiceProvider).updateSettings( + settings.copyWith( + videoPlayerMuted: snapshot.muted, + videoPlayerHalfVolume: snapshot.halfVolume, + videoPlayerLoop: snapshot.loopVideo, + videoPlayerCover: snapshot.coverVideo, + ), + ); + ref.invalidate(appSettingsProvider); + } +} + +class _MobilePostPager extends StatefulWidget { + const _MobilePostPager({ + required this.posts, + required this.initialIndex, + required this.buildDetails, + }); + + final List posts; + final int initialIndex; + final Widget Function(BuildContext context, Post post) buildDetails; + + @override + State<_MobilePostPager> createState() => _MobilePostPagerState(); +} + class _MobilePostPagerState extends State<_MobilePostPager> { late final PageController _controller; - late int _currentIndex; @override void initState() { super.initState(); - _currentIndex = widget.initialIndex; _controller = PageController(initialPage: widget.initialIndex); - WidgetsBinding.instance.addPostFrameCallback((_) { - _prefetchAround(widget.initialIndex); - }); - } - - @override - void didUpdateWidget(covariant _MobilePostPager oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.initialIndex != _currentIndex && + WidgetsBinding.instance.addPostFrameCallback((_) { + _prefetchAround(widget.initialIndex); + }); + } + + @override + void didUpdateWidget(covariant _MobilePostPager oldWidget) { + super.didUpdateWidget(oldWidget); + // Исправленная проверка: сравниваем старый и новый initialIndex, а не текущий свайп + if (oldWidget.initialIndex != widget.initialIndex && widget.initialIndex >= 0 && widget.initialIndex < widget.posts.length) { - _currentIndex = widget.initialIndex; if (_controller.hasClients) { _controller.jumpToPage(widget.initialIndex); - } - WidgetsBinding.instance.addPostFrameCallback((_) { - _prefetchAround(widget.initialIndex); - }); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PageView.builder( + } + WidgetsBinding.instance.addPostFrameCallback((_) { + _prefetchAround(widget.initialIndex); + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PageView.builder( controller: _controller, itemCount: widget.posts.length, onPageChanged: (index) { - _currentIndex = index; _prefetchAround(index); }, - itemBuilder: (context, index) { - return _KeepAlivePostPage( - child: KeyedSubtree( - key: ValueKey(widget.posts[index].cacheKey), - child: widget.buildDetails(context, widget.posts[index]), - ), - ); - }, - ); - } - - void _prefetchAround(int index) { - for (final offset in [-2, -1, 0, 1, 2]) { - final target = index + offset; - if (target < 0 || target >= widget.posts.length) continue; - final post = widget.posts[target]; - final urls = [ - post.previewUrl, - post.sampleUrl, - if (post.fileType.toLowerCase().contains('gif')) post.fileUrl, - ].where((url) => url.trim().isNotEmpty).toSet(); - for (final url in urls) { - precacheImage( - CachedNetworkImageProvider(url, headers: _headersFor(post)), - context, - ); - } - } - } - - Map _headersFor(Post post) { - return { - 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', - 'Accept': '*/*', - if (post.providerName.toLowerCase().contains('gelbooru') || - post.fileUrl.contains('gelbooru.com') || - post.sampleUrl.contains('gelbooru.com') || - post.previewUrl.contains('gelbooru.com')) - 'Referer': 'https://gelbooru.com/', - if (post.providerName.toLowerCase().contains('rule34') || - post.fileUrl.contains('rule34') || - post.sampleUrl.contains('rule34') || - post.previewUrl.contains('rule34')) - 'Referer': 'https://rule34.xxx/', - }; - } -} - -class _KeepAlivePostPage extends StatefulWidget { - const _KeepAlivePostPage({required this.child}); - - final Widget child; - - @override - State<_KeepAlivePostPage> createState() => _KeepAlivePostPageState(); -} - -class _KeepAlivePostPageState extends State<_KeepAlivePostPage> - with AutomaticKeepAliveClientMixin { - @override - bool get wantKeepAlive => true; - - @override - Widget build(BuildContext context) { - super.build(context); - return widget.child; - } -} - -class _NeighborStrip extends StatelessWidget { - const _NeighborStrip({ - required this.posts, - required this.currentIndex, - required this.onOpen, - }); - - final List posts; - final int currentIndex; - final ValueChanged onOpen; - - @override - Widget build(BuildContext context) { - final start = (currentIndex - 3).clamp(0, posts.length); - final end = (currentIndex + 4).clamp(0, posts.length); - final visible = posts.sublist(start, end); - return SizedBox( - height: 72, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: visible.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final post = visible[index]; - final selected = post.cacheKey == posts[currentIndex].cacheKey; - return InkWell( - onTap: () => onOpen(post), - borderRadius: BorderRadius.circular(8), - child: AnimatedContainer( - duration: AppMotion.duration(context, 140), - width: 72, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: selected - ? Theme.of(context).colorScheme.primary - : Colors.transparent, - width: 2, - ), - ), - clipBehavior: Clip.antiAlias, - child: CachedNetworkImage( - imageUrl: post.previewUrl.isNotEmpty - ? post.previewUrl - : post.sampleUrl, - fit: BoxFit.cover, - errorWidget: (_, __, ___) => - const Center(child: Icon(Icons.broken_image_rounded)), - ), - ), - ); - }, - ), - ); - } -} - -class _PreviousPostIntent extends Intent { - const _PreviousPostIntent(); -} - -class _NextPostIntent extends Intent { - const _NextPostIntent(); -} - -class _ToggleFavoriteIntent extends Intent { - const _ToggleFavoriteIntent(); -} - -class _AddCollectionIntent extends Intent { - const _AddCollectionIntent(); -} - -class _CloseIntent extends Intent { - const _CloseIntent(); -} - -class _DownloadIntent extends Intent { - const _DownloadIntent(); -} - -class _CommentsSection extends ConsumerStatefulWidget { - const _CommentsSection({required this.post}); - - final Post post; - - @override - ConsumerState<_CommentsSection> createState() => _CommentsSectionState(); -} - -class _CommentsSectionState extends ConsumerState<_CommentsSection> { - bool _expanded = false; - - @override - Widget build(BuildContext context) { - final strings = ref.watch(appStringsProvider); - final comments = _expanded - ? ref.watch( - postCommentsProvider( - PostDetailsArgs( - providerId: widget.post.providerId, - postId: widget.post.id, - ), - ), - ) - : null; - return ExpansionTile( - tilePadding: EdgeInsets.zero, - initiallyExpanded: false, - onExpansionChanged: (value) => setState(() => _expanded = value), - title: Text(strings.comments, - style: Theme.of(context).textTheme.titleMedium), - children: [ - (comments ?? const AsyncValue>.data([])).when( - loading: () => const Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator(), - ), - error: (_, __) => Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(strings.commentsUnavailable), - ), - ), - data: (items) { - if (items.isEmpty) { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(strings.noComments), - ), - ); - } - return Column( - children: [ - for (final comment in items) - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - title: Text( - comment.authorName.isEmpty - ? strings.anonymous - : comment.authorName, - ), - subtitle: Text(comment.body), - ), - ], - ); - }, - ), - ], - ); - } -} + itemBuilder: (context, index) { + return _KeepAlivePostPage( + child: KeyedSubtree( + key: ValueKey(widget.posts[index].cacheKey), + child: widget.buildDetails(context, widget.posts[index]), + ), + ); + }, + ); + } + + void _prefetchAround(int index) { + for (final offset in [-2, -1, 0, 1, 2]) { + final target = index + offset; + if (target < 0 || target >= widget.posts.length) continue; + final post = widget.posts[target]; + final urls = [ + post.previewUrl, + post.sampleUrl, + if (post.fileType.toLowerCase().contains('gif')) post.fileUrl, + ].where((url) => url.trim().isNotEmpty).toSet(); + for (final url in urls) { + precacheImage( + CachedNetworkImageProvider(url, headers: _headersFor(post)), + context, + ); + } + } + } + + Map _headersFor(Post post) { + return { + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + 'Accept': '*/*', + if (post.providerName.toLowerCase().contains('gelbooru') || + post.fileUrl.contains('gelbooru.com') || + post.sampleUrl.contains('gelbooru.com') || + post.previewUrl.contains('gelbooru.com')) + 'Referer': 'https://gelbooru.com/', + if (post.providerName.toLowerCase().contains('rule34') || + post.fileUrl.contains('rule34') || + post.sampleUrl.contains('rule34') || + post.previewUrl.contains('rule34')) + 'Referer': 'https://rule34.xxx/', + }; + } +} + +class _KeepAlivePostPage extends StatefulWidget { + const _KeepAlivePostPage({required this.child}); + + final Widget child; + + @override + State<_KeepAlivePostPage> createState() => _KeepAlivePostPageState(); +} + +class _KeepAlivePostPageState extends State<_KeepAlivePostPage> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} + +class _NeighborStrip extends StatelessWidget { + const _NeighborStrip({ + required this.posts, + required this.currentIndex, + required this.onOpen, + }); + + final List posts; + final int currentIndex; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final start = (currentIndex - 3).clamp(0, posts.length); + final end = (currentIndex + 4).clamp(0, posts.length); + final visible = posts.sublist(start, end); + return SizedBox( + height: 72, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: visible.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final post = visible[index]; + final selected = post.cacheKey == posts[currentIndex].cacheKey; + return InkWell( + onTap: () => onOpen(post), + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: AppMotion.duration(context, 140), + width: 72, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 2, + ), + ), + clipBehavior: Clip.antiAlias, + child: CachedNetworkImage( + imageUrl: post.previewUrl.isNotEmpty + ? post.previewUrl + : post.sampleUrl, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => + const Center(child: Icon(Icons.broken_image_rounded)), + ), + ), + ); + }, + ), + ); + } +} + +class _PreviousPostIntent extends Intent { + const _PreviousPostIntent(); +} + +class _NextPostIntent extends Intent { + const _NextPostIntent(); +} + +class _ToggleFavoriteIntent extends Intent { + const _ToggleFavoriteIntent(); +} + +class _AddCollectionIntent extends Intent { + const _AddCollectionIntent(); +} + +class _CloseIntent extends Intent { + const _CloseIntent(); +} + +class _DownloadIntent extends Intent { + const _DownloadIntent(); +} + +class _CommentsSection extends ConsumerStatefulWidget { + const _CommentsSection({required this.post}); + + final Post post; + + @override + ConsumerState<_CommentsSection> createState() => _CommentsSectionState(); +} + +class _CommentsSectionState extends ConsumerState<_CommentsSection> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final strings = ref.watch(appStringsProvider); + final comments = _expanded + ? ref.watch( + postCommentsProvider( + PostDetailsArgs( + providerId: widget.post.providerId, + postId: widget.post.id, + ), + ), + ) + : null; + return ExpansionTile( + tilePadding: EdgeInsets.zero, + initiallyExpanded: false, + onExpansionChanged: (value) => setState(() => _expanded = value), + title: Text(strings.comments, + style: Theme.of(context).textTheme.titleMedium), + children: [ + (comments ?? const AsyncValue>.data([])).when( + loading: () => const Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(), + ), + error: (_, __) => Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(strings.commentsUnavailable), + ), + ), + data: (items) { + if (items.isEmpty) { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(strings.noComments), + ), + ); + } + return Column( + children: [ + for (final comment in items) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + comment.authorName.isEmpty + ? strings.anonymous + : comment.authorName, + ), + subtitle: Text(comment.body), + ), + ], + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 3d976e9..0812480 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -1,1504 +1,1504 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:webview_windows/webview_windows.dart'; - -import '../../../../app/motion.dart'; -import '../../../../backend/backend.dart'; - -final Map _playbackMemory = - {}; - -class PostMediaViewer extends StatefulWidget { - const PostMediaViewer({ - required this.post, - this.fullscreen = false, - this.initialPosition = Duration.zero, - this.autoplay = false, - this.initialLoop = false, - this.initialMuted = false, - this.initialCoverVideo = false, - this.initialHalfVolume = false, - this.qualityMode = MediaQualityMode.auto, - this.onPlaybackSnapshot, - this.onPlaybackPreferencesChanged, - super.key, - }); - - final Post post; - final bool fullscreen; - final Duration initialPosition; - final bool autoplay; - final bool initialLoop; - final bool initialMuted; - final bool initialCoverVideo; - final bool initialHalfVolume; - final MediaQualityMode qualityMode; - final ValueChanged? onPlaybackSnapshot; - final ValueChanged? onPlaybackPreferencesChanged; - - @override - State createState() => _PostMediaViewerState(); -} - -class _PostMediaViewerState extends State - with AutomaticKeepAliveClientMixin { - Player? _player; - VideoController? _controller; - late List _imageUrls; - late List _videoUrls; - int _imageIndex = 0; - int _videoIndex = 0; - bool _controlsVisible = true; - bool _inFullscreen = false; - late bool _coverVideo; - late bool _muted; - late bool _loopVideo; - late bool _halfVolume; - String? _videoError; - Timer? _hideTimer; - StreamSubscription? _errorSubscription; - StreamSubscription? _positionSubscription; - StreamSubscription? _playingSubscription; - - @override - void initState() { - super.initState(); - _coverVideo = widget.initialCoverVideo; - _muted = widget.initialMuted; - _loopVideo = widget.initialLoop; - _halfVolume = widget.initialHalfVolume; - _imageUrls = _buildImageUrls(widget.post); - _videoUrls = _buildVideoUrls(widget.post); - if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { - _initializeVideo(); - } - } - - @override - void didUpdateWidget(covariant PostMediaViewer oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.post.cacheKey != widget.post.cacheKey) { - _disposeVideo(); - _imageIndex = 0; - _videoIndex = 0; - _videoError = null; - _imageUrls = _buildImageUrls(widget.post); - _videoUrls = _buildVideoUrls(widget.post); - if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { - _initializeVideo(); - } - } - } - - @override - void dispose() { - _disposeVideo(); - super.dispose(); - } - - @override - bool get wantKeepAlive => true; - - @override - Widget build(BuildContext context) { - super.build(context); - if (_isSwf(widget.post)) { - return _SwfMediaViewer( - post: widget.post, - url: _swfUrl(widget.post), - fullscreen: widget.fullscreen, - ); - } - - if (_controller != null) { - if (_inFullscreen) { - return ColoredBox( - color: Colors.black, - child: AspectRatio( - aspectRatio: widget.post.width > 0 && widget.post.height > 0 - ? (widget.post.width / widget.post.height).clamp(0.35, 2.4) - : 16 / 9, - child: const Center( - child: Icon(Icons.fullscreen_rounded, color: Colors.white38), - ), - ), - ); - } - return Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), - }, - child: Actions( - actions: { - _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( - onInvoke: (_) { - _togglePlay(); - return null; - }, - ), - }, - child: Focus( - autofocus: true, - child: _VideoSurface( - player: _player!, - controller: _controller!, - aspectRatio: widget.post.width > 0 && widget.post.height > 0 - ? widget.post.width / widget.post.height - : 16 / 9, - controlsVisible: _controlsVisible, - coverVideo: _coverVideo, - muted: _muted, - loopVideo: _loopVideo, - halfVolume: _halfVolume, - fullscreen: widget.fullscreen, - errorMessage: _videoError, - onInteract: _showControls, - onRetry: _retryVideo, - onToggleFit: () { - setState(() => _coverVideo = !_coverVideo); - _emitPlaybackPreferences(); - _showControls(); - }, - onToggleMute: () async { - final nextMuted = !_muted; - setState(() => _muted = nextMuted); - await _applyVolume(); - _emitPlaybackPreferences(); - _showControls(); - }, - onToggleHalfVolume: () async { - setState(() { - _halfVolume = !_halfVolume; - if (_halfVolume) _muted = false; - }); - await _applyVolume(); - _emitPlaybackPreferences(); - _showControls(); - }, - onToggleLoop: () async { - final nextLoop = !_loopVideo; - setState(() => _loopVideo = nextLoop); - await _player!.setPlaylistMode( - nextLoop ? PlaylistMode.single : PlaylistMode.none, - ); - _emitPlaybackPreferences(); - _showControls(); - }, - onFullscreen: widget.fullscreen - ? () => Navigator.of(context, rootNavigator: true) - .pop(_snapshot()) - : () => _openFullscreen(context), - ), - ), - ), - ); - } - - final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; - final image = CachedNetworkImage( - key: ValueKey(url), - imageUrl: url, - httpHeaders: _headersFor(widget.post), - fit: BoxFit.contain, - placeholder: (context, url) => - const Center(child: CircularProgressIndicator()), - errorWidget: (context, url, error) { - if (_imageIndex < _imageUrls.length - 1) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _imageIndex++); - }); - return const Center(child: CircularProgressIndicator()); - } - return const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.broken_image_rounded, size: 48), - SizedBox(height: 8), - Text('Could not load image'), - ], - ), - ); - }, - ); - final allowPinchZoom = MediaQuery.sizeOf(context).width < 700; - return allowPinchZoom ? _ZoomableImage(child: image) : image; - } - - void _initializeVideo() { - MediaKit.ensureInitialized(); - _player = Player(); - _controller = VideoController(_player!); - _applyVolume(); - _player!.setPlaylistMode( - _loopVideo ? PlaylistMode.single : PlaylistMode.none, - ); - _errorSubscription = _player!.stream.error.listen((message) { - if (!mounted) return; - if (_videoIndex < _videoUrls.length - 1) { - _videoIndex++; - _openVideo(play: false); - return; - } - setState(() => _videoError = message); - _showControls(); - }); - _positionSubscription = _player!.stream.position.listen((_) { - final snapshot = _snapshot(); - _playbackMemory[widget.post.cacheKey] = snapshot; - widget.onPlaybackSnapshot?.call(snapshot); - }); - _playingSubscription = _player!.stream.playing.listen((_) { - final snapshot = _snapshot(); - _playbackMemory[widget.post.cacheKey] = snapshot; - widget.onPlaybackSnapshot?.call(snapshot); - }); - _openVideo(play: widget.autoplay); - _scheduleControlsHide(); - } - - Future _openVideo({required bool play}) async { - final player = _player; - if (player == null || _videoUrls.isEmpty) return; - final remembered = _playbackMemory[widget.post.cacheKey]; - final initialPosition = widget.initialPosition > Duration.zero - ? widget.initialPosition - : remembered?.position ?? Duration.zero; - setState(() => _videoError = null); - await player.open( - Media( - _videoUrls[_videoIndex], - httpHeaders: _headersFor(widget.post), - ), - play: false, - ); - if (initialPosition > Duration.zero) { - await player.seek(initialPosition); - } - if (play) await player.play(); - } - - Future _retryVideo() async { - final player = _player; - if (player == null) return; - setState(() { - _videoError = null; - _videoIndex = 0; - _controlsVisible = true; - }); - await player.stop(); - await _openVideo(play: true); - _showControls(); - } - - void _disposeVideo() { - _hideTimer?.cancel(); - _hideTimer = null; - _errorSubscription?.cancel(); - _errorSubscription = null; - _positionSubscription?.cancel(); - _positionSubscription = null; - _playingSubscription?.cancel(); - _playingSubscription = null; - final snapshot = _snapshot(); - _playbackMemory[widget.post.cacheKey] = snapshot; - widget.onPlaybackSnapshot?.call(snapshot); - _player?.dispose(); - _player = null; - _controller = null; - } - - List _buildImageUrls(Post post) { - return MediaUrlSelector.details(post, mode: widget.qualityMode); - } - - List _buildVideoUrls(Post post) { - return MediaUrlSelector.video(post); - } - - bool _isVideo(Post post) { - final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); - return value.contains('video') || - value.contains('.webm') || - value.contains('.mp4') || - value.contains('.mov'); - } - - bool _isSwf(Post post) { - final value = - '${post.fileType} ${post.fileUrl} ${post.sampleUrl} ${post.source ?? ''}' - .toLowerCase(); - return value.contains('swf') || value.contains('.swf'); - } - - String _swfUrl(Post post) { - for (final url in [post.fileUrl, post.sampleUrl, post.source ?? '']) { - final value = url.trim(); - if (value.toLowerCase().contains('.swf')) return value; - } - return post.fileUrl.trim(); - } - - Map _headersFor(Post post) { - return { - 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', - 'Accept': '*/*', - if (post.providerName.toLowerCase().contains('gelbooru') || - post.fileUrl.contains('gelbooru.com') || - post.sampleUrl.contains('gelbooru.com') || - post.previewUrl.contains('gelbooru.com')) - 'Referer': 'https://gelbooru.com/', - if (post.providerName.toLowerCase().contains('rule34') || - post.fileUrl.contains('rule34') || - post.sampleUrl.contains('rule34') || - post.previewUrl.contains('rule34')) - 'Referer': 'https://rule34.xxx/', - }; - } - - void _showControls() { - if (!mounted) return; - setState(() => _controlsVisible = true); - _scheduleControlsHide(); - } - - void _scheduleControlsHide() { - _hideTimer?.cancel(); - _hideTimer = Timer(const Duration(seconds: 3), () { - if (mounted && _videoError == null) { - setState(() => _controlsVisible = false); - } - }); - } - - Future _openFullscreen(BuildContext context) async { - final player = _player; - final controller = _controller; - if (player == null || controller == null) return; - setState(() => _inFullscreen = true); - if (!context.mounted) return; - final result = await Navigator.of(context, rootNavigator: true) - .push( - PageRouteBuilder( - opaque: true, - pageBuilder: (_, __, ___) => _FullscreenVideoPage( - player: player, - controller: controller, - aspectRatio: widget.post.width > 0 && widget.post.height > 0 - ? widget.post.width / widget.post.height - : 16 / 9, - loopVideo: _loopVideo, - muted: _muted, - halfVolume: _halfVolume, - coverVideo: _coverVideo, - errorMessage: _videoError, - onRetry: _retryVideo, - onChanged: (snapshot) { - unawaited(_applyPlaybackSnapshot(snapshot)); - }, - ), - transitionsBuilder: (_, animation, __, child) => FadeTransition( - opacity: animation, - child: child, - ), - ), - ); - if (mounted) setState(() => _inFullscreen = false); - if (result != null) { - await _applyPlaybackSnapshot(result); - return; - } - } - - Future _applyPlaybackSnapshot(VideoPlaybackSnapshot snapshot) async { - if (!mounted) return; - setState(() { - _muted = snapshot.muted; - _loopVideo = snapshot.loopVideo; - _coverVideo = snapshot.coverVideo; - _halfVolume = snapshot.halfVolume; - }); - await _applyVolume(); - await _player?.setPlaylistMode( - _loopVideo ? PlaylistMode.single : PlaylistMode.none, - ); - _emitPlaybackPreferences(); - } - - VideoPlaybackSnapshot _snapshot() { - final player = _player; - return VideoPlaybackSnapshot( - position: player?.state.position ?? Duration.zero, - playing: player?.state.playing ?? false, - muted: _muted, - halfVolume: _halfVolume, - loopVideo: _loopVideo, - coverVideo: _coverVideo, - ); - } - - Future _applyVolume() async { - await _player?.setVolume(_muted - ? 0 - : _halfVolume - ? 50 - : 100); - } - - void _emitPlaybackPreferences() { - final snapshot = _snapshot(); - _playbackMemory[widget.post.cacheKey] = snapshot; - widget.onPlaybackSnapshot?.call(snapshot); - widget.onPlaybackPreferencesChanged?.call(snapshot); - } - - Future _togglePlay() async { - final player = _player; - if (player == null) return; - if (player.state.playing) { - await player.pause(); - } else { - await player.play(); - } - _showControls(); - } -} - -class _TogglePlayIntent extends Intent { - const _TogglePlayIntent(); -} - -class _SwfMediaViewer extends StatelessWidget { - const _SwfMediaViewer({ - required this.post, - required this.url, - required this.fullscreen, - }); - - final Post post; - final String url; - final bool fullscreen; - - @override - Widget build(BuildContext context) { - if (url.isEmpty) { - return const Center(child: Text('SWF URL is empty')); - } - if (Platform.isWindows) { - return _WindowsRuffleViewer(url: url, fullscreen: fullscreen); - } - return _SwfUnsupportedPanel(url: url); - } -} - -class _WindowsRuffleViewer extends StatefulWidget { - const _WindowsRuffleViewer({ - required this.url, - required this.fullscreen, - }); - - final String url; - final bool fullscreen; - - @override - State<_WindowsRuffleViewer> createState() => _WindowsRuffleViewerState(); -} - -class _WindowsRuffleViewerState extends State<_WindowsRuffleViewer> { - final _controller = WebviewController(); - bool _loading = true; - String? _error; - - @override - void initState() { - super.initState(); - _initialize(); - } - - @override - void didUpdateWidget(covariant _WindowsRuffleViewer oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.url != widget.url) { - _load(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final child = ClipRRect( - borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10), - child: Stack( - fit: StackFit.expand, - children: [ - if (_controller.value.isInitialized) Webview(_controller), - if (_loading) - const ColoredBox( - color: Colors.black, - child: Center(child: CircularProgressIndicator()), - ), - if (_error != null) - _SwfErrorPanel( - message: _error!, - url: widget.url, - onRetry: _load, - ), - ], - ), - ); - if (widget.fullscreen) return child; - return AspectRatio(aspectRatio: 4 / 3, child: child); - } - - Future _initialize() async { - try { - await _controller.initialize(); - _controller.loadingState.listen((state) { - if (!mounted) return; - setState(() => _loading = state == LoadingState.loading); - }); - _controller.onLoadError.listen((error) { - if (!mounted) return; - setState(() => _error = 'Ruffle WebView error: $error'); - }); - await _load(); - } catch (error) { - if (!mounted) return; - setState(() { - _loading = false; - _error = - 'Could not start Windows WebView2. Install WebView2 Runtime or open original.'; - }); - } - } - - Future _load() async { - if (!_controller.value.isInitialized) return; - setState(() { - _loading = true; - _error = null; - }); - try { - await _controller.loadStringContent(_ruffleHtml(widget.url)); - } catch (error) { - if (!mounted) return; - setState(() { - _loading = false; - _error = 'Could not load Ruffle viewer: $error'; - }); - } - } - - String _ruffleHtml(String swfUrl) { - final escapedUrl = _htmlEscape(swfUrl); - return ''' - - - - - - - - - - -
- -
- - -'''; - } - - String _htmlEscape(String value) { - return value - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll("'", ''') - .replaceAll('<', '<') - .replaceAll('>', '>'); - } -} - -class _SwfUnsupportedPanel extends StatelessWidget { - const _SwfUnsupportedPanel({required this.url}); - - final String url; - - @override - Widget build(BuildContext context) { - return _SwfErrorPanel( - message: Platform.isAndroid - ? 'Flash/SWF playback is desktop only.' - : 'SWF playback is available on Windows only in this build.', - url: url, - ); - } -} - -class _SwfErrorPanel extends StatelessWidget { - const _SwfErrorPanel({ - required this.message, - required this.url, - this.onRetry, - }); - - final String message; - final String url; - final VoidCallback? onRetry; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return ColoredBox( - color: Colors.black, - child: Center( - child: Padding( - padding: const EdgeInsets.all(22), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.extension_rounded, - color: Colors.white, size: 48), - const SizedBox(height: 12), - Text( - 'SWF / Flash', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: Colors.white), - ), - const SizedBox(height: 8), - Text( - message, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - if (onRetry != null) - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), - ), - FilledButton.tonalIcon( - onPressed: () => launchUrl( - Uri.parse(url), - mode: LaunchMode.externalApplication, - ), - icon: const Icon(Icons.open_in_new_rounded), - label: const Text('Open original'), - ), - FilledButton.tonalIcon( - style: FilledButton.styleFrom( - backgroundColor: - scheme.primaryContainer.withValues(alpha: 0.82), - ), - onPressed: () => launchUrl( - Uri.parse( - 'https://ruffle.rs/demo/?url=${Uri.encodeComponent(url)}', - ), - mode: LaunchMode.externalApplication, - ), - icon: const Icon(Icons.play_circle_outline_rounded), - label: const Text('Open in Ruffle'), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class VideoPlaybackSnapshot { - const VideoPlaybackSnapshot({ - required this.position, - required this.playing, - required this.muted, - required this.halfVolume, - required this.loopVideo, - required this.coverVideo, - }); - - final Duration position; - final bool playing; - final bool muted; - final bool halfVolume; - final bool loopVideo; - final bool coverVideo; -} - -class _FullscreenVideoPage extends StatefulWidget { - const _FullscreenVideoPage({ - required this.player, - required this.controller, - required this.aspectRatio, - required this.loopVideo, - required this.muted, - required this.halfVolume, - required this.coverVideo, - required this.errorMessage, - required this.onRetry, - required this.onChanged, - }); - - final Player player; - final VideoController controller; - final double aspectRatio; - final bool loopVideo; - final bool muted; - final bool halfVolume; - final bool coverVideo; - final String? errorMessage; - final VoidCallback onRetry; - final ValueChanged onChanged; - - @override - State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState(); -} - -class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { - bool _controlsVisible = true; - late bool _coverVideo; - late bool _muted; - late bool _loopVideo; - late bool _halfVolume; - Timer? _hideTimer; - - @override - void initState() { - super.initState(); - _coverVideo = widget.coverVideo; - _muted = widget.muted; - _loopVideo = widget.loopVideo; - _halfVolume = widget.halfVolume; - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - DeviceOrientation.portraitUp, - ]); - _scheduleControlsHide(); - } - - @override - void dispose() { - _hideTimer?.cancel(); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - SystemChrome.setPreferredOrientations(DeviceOrientation.values); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - Navigator.of(context, rootNavigator: true).pop(_snapshot()); - }, - child: Scaffold( - backgroundColor: Colors.black, - body: Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(), - LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), - }, - child: Actions( - actions: { - _CloseVideoIntent: CallbackAction<_CloseVideoIntent>( - onInvoke: (_) { - Navigator.of(context, rootNavigator: true).pop(_snapshot()); - return null; - }, - ), - _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( - onInvoke: (_) { - widget.player.playOrPause(); - _showControls(); - return null; - }, - ), - }, - child: Focus( - autofocus: true, - child: Stack( - children: [ - Positioned.fill( - child: _VideoSurface( - player: widget.player, - controller: widget.controller, - aspectRatio: widget.aspectRatio, - controlsVisible: _controlsVisible, - coverVideo: _coverVideo, - muted: _muted, - loopVideo: _loopVideo, - halfVolume: _halfVolume, - fullscreen: true, - errorMessage: widget.errorMessage, - onInteract: _showControls, - onRetry: widget.onRetry, - onToggleFit: () { - setState(() => _coverVideo = !_coverVideo); - widget.onChanged(_snapshot()); - _showControls(); - }, - onToggleMute: () async { - setState(() => _muted = !_muted); - await _applyVolume(); - widget.onChanged(_snapshot()); - _showControls(); - }, - onToggleHalfVolume: () async { - setState(() { - _halfVolume = !_halfVolume; - if (_halfVolume) _muted = false; - }); - await _applyVolume(); - widget.onChanged(_snapshot()); - _showControls(); - }, - onToggleLoop: () async { - setState(() => _loopVideo = !_loopVideo); - await widget.player.setPlaylistMode( - _loopVideo ? PlaylistMode.single : PlaylistMode.none, - ); - widget.onChanged(_snapshot()); - _showControls(); - }, - onFullscreen: () => Navigator.of( - context, - rootNavigator: true, - ).pop(_snapshot()), - ), - ), - Positioned( - top: 12, - right: 12, - child: SafeArea( - child: IconButton.filledTonal( - tooltip: 'Close fullscreen', - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(_snapshot()), - icon: const Icon(Icons.close_rounded), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } - - void _showControls() { - if (!mounted) return; - setState(() => _controlsVisible = true); - _scheduleControlsHide(); - } - - void _scheduleControlsHide() { - _hideTimer?.cancel(); - _hideTimer = Timer(const Duration(seconds: 3), () { - if (mounted && widget.errorMessage == null) { - setState(() => _controlsVisible = false); - } - }); - } - - Future _applyVolume() async { - await widget.player.setVolume(_muted - ? 0 - : _halfVolume - ? 50 - : 100); - } - - VideoPlaybackSnapshot _snapshot() { - return VideoPlaybackSnapshot( - position: widget.player.state.position, - playing: widget.player.state.playing, - muted: _muted, - halfVolume: _halfVolume, - loopVideo: _loopVideo, - coverVideo: _coverVideo, - ); - } -} - -class _CloseVideoIntent extends Intent { - const _CloseVideoIntent(); -} - -class _VideoSurface extends StatelessWidget { - const _VideoSurface({ - required this.player, - required this.controller, - required this.aspectRatio, - required this.controlsVisible, - required this.coverVideo, - required this.muted, - required this.halfVolume, - required this.loopVideo, - required this.fullscreen, - required this.errorMessage, - required this.onInteract, - required this.onRetry, - required this.onToggleFit, - required this.onToggleMute, - required this.onToggleHalfVolume, - required this.onToggleLoop, - required this.onFullscreen, - }); - - final Player player; - final VideoController controller; - final double aspectRatio; - final bool controlsVisible; - final bool coverVideo; - final bool muted; - final bool halfVolume; - final bool loopVideo; - final bool fullscreen; - final String? errorMessage; - final VoidCallback onInteract; - final VoidCallback onRetry; - final VoidCallback onToggleFit; - final VoidCallback onToggleMute; - final VoidCallback onToggleHalfVolume; - final VoidCallback onToggleLoop; - final VoidCallback onFullscreen; - - @override - Widget build(BuildContext context) { - final child = Stack( - fit: StackFit.expand, - children: [ - ColoredBox( - color: Colors.black, - child: Video( - controller: controller, - fit: coverVideo ? BoxFit.cover : BoxFit.contain, - controls: null, - ), - ), - StreamBuilder( - stream: player.stream.buffering, - initialData: player.state.buffering, - builder: (context, snapshot) { - if (snapshot.data != true || errorMessage != null) { - return const SizedBox.shrink(); - } - return const Center(child: CircularProgressIndicator()); - }, - ), - if (errorMessage != null) - VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), - if (errorMessage == null) - StreamBuilder( - stream: player.stream.playing, - initialData: player.state.playing, - builder: (context, snapshot) { - final playing = snapshot.data ?? false; - return AnimatedScale( - scale: controlsVisible || !playing ? 1 : 0.85, - duration: AppMotion.duration(context, 160), - child: AnimatedOpacity( - opacity: controlsVisible || !playing ? 1 : 0, - duration: AppMotion.duration(context, 160), - child: Center( - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.46), - shape: BoxShape.circle, - boxShadow: const [ - BoxShadow( - color: Colors.black45, - blurRadius: 24, - offset: Offset(0, 10), - ), - ], - ), - child: IconButton( - tooltip: playing ? 'Pause' : 'Play', - iconSize: fullscreen ? 58 : 44, - color: Colors.white, - onPressed: () { - player.playOrPause(); - onInteract(); - }, - icon: Icon( - playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - ), - ), - ), - ), - ); - }, - ), - IgnorePointer( - ignoring: !controlsVisible || errorMessage != null, - child: AnimatedOpacity( - opacity: controlsVisible || errorMessage != null ? 1 : 0, - duration: AppMotion.duration(context, 180), - child: _VideoControls( - player: player, - muted: muted, - halfVolume: halfVolume, - loopVideo: loopVideo, - coverVideo: coverVideo, - fullscreen: fullscreen, - onToggleFit: onToggleFit, - onToggleMute: onToggleMute, - onToggleHalfVolume: onToggleHalfVolume, - onToggleLoop: onToggleLoop, - onFullscreen: onFullscreen, - ), - ), - ), - ], - ); - - return MouseRegion( - onHover: (_) => onInteract(), - child: GestureDetector( - onTap: onInteract, - onLongPress: player.playOrPause, - onDoubleTap: onFullscreen, - child: ClipRRect( - borderRadius: BorderRadius.circular(fullscreen ? 0 : 10), - child: fullscreen - ? SizedBox.expand(child: child) - : AspectRatio( - aspectRatio: aspectRatio.clamp(0.35, 2.4), - child: child, - ), - ), - ), - ); - } -} - -class VideoErrorOverlay extends StatelessWidget { - const VideoErrorOverlay({ - required this.message, - required this.onRetry, - super.key, - }); - - final String message; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return ColoredBox( - color: Colors.black.withValues(alpha: 0.62), - child: Center( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline_rounded, - color: Colors.white, size: 42), - const SizedBox(height: 10), - Text( - 'Could not load video', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: Colors.white), - ), - const SizedBox(height: 6), - Text( - message, - maxLines: 3, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 14), - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry'), - ), - ], - ), - ), - ), - ); - } -} - -class _ZoomableImage extends StatefulWidget { - const _ZoomableImage({required this.child}); - - final Widget child; - - @override - State<_ZoomableImage> createState() => _ZoomableImageState(); -} - -class _ZoomableImageState extends State<_ZoomableImage> { - final _controller = TransformationController(); - TapDownDetails? _doubleTapDetails; - bool _zoomed = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onDoubleTapDown: (details) => _doubleTapDetails = details, - onDoubleTap: _toggleZoom, - child: InteractiveViewer( - transformationController: _controller, - minScale: 1, - maxScale: 5, - panEnabled: _zoomed, - scaleEnabled: true, - clipBehavior: Clip.none, - onInteractionEnd: (_) { - final scale = _controller.value.getMaxScaleOnAxis(); - if (mounted) setState(() => _zoomed = scale > 1.03); - }, - child: widget.child, - ), - ); - } - - void _toggleZoom() { - final tap = _doubleTapDetails?.localPosition ?? Offset.zero; - if (_zoomed) { - _controller.value = Matrix4.identity(); - setState(() => _zoomed = false); - return; - } - _controller.value = Matrix4.identity() - ..translateByDouble(-tap.dx * 1.3, -tap.dy * 1.3, 0, 1) - ..scaleByDouble(2.3, 2.3, 1, 1); - setState(() => _zoomed = true); - } -} - -class _RoundControlButton extends StatelessWidget { - const _RoundControlButton({ - required this.tooltip, - required this.icon, - required this.onPressed, - this.emphasized = false, - this.selected = false, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - final bool emphasized; - final bool selected; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final background = emphasized - ? scheme.primary - : selected - ? scheme.primaryContainer.withValues(alpha: 0.96) - : Colors.white.withValues(alpha: 0.13); - final foreground = emphasized - ? scheme.onPrimary - : selected - ? scheme.onPrimaryContainer - : Colors.white; - return Tooltip( - message: tooltip, - child: InkResponse( - onTap: onPressed, - radius: emphasized ? 23 : 21, - child: AnimatedContainer( - duration: AppMotion.duration(context, 140), - width: emphasized ? 38 : 34, - height: emphasized ? 38 : 34, - decoration: BoxDecoration( - color: background, - shape: BoxShape.circle, - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Icon(icon, color: foreground, size: emphasized ? 24 : 20), - ), - ), - ); - } -} - -class _VideoControls extends StatelessWidget { - const _VideoControls({ - required this.player, - required this.muted, - required this.halfVolume, - required this.loopVideo, - required this.coverVideo, - required this.fullscreen, - required this.onToggleFit, - required this.onToggleMute, - required this.onToggleHalfVolume, - required this.onToggleLoop, - required this.onFullscreen, - }); - - final Player player; - final bool muted; - final bool halfVolume; - final bool loopVideo; - final bool coverVideo; - final bool fullscreen; - final VoidCallback onToggleFit; - final VoidCallback onToggleMute; - final VoidCallback onToggleHalfVolume; - final VoidCallback onToggleLoop; - final VoidCallback onFullscreen; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Align( - alignment: Alignment.bottomCenter, - child: SafeArea( - minimum: EdgeInsets.fromLTRB( - fullscreen ? 14 : 8, - 0, - fullscreen ? 14 : 8, - fullscreen ? 10 : 8, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.58), - borderRadius: BorderRadius.circular(fullscreen ? 18 : 14), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), - child: StreamBuilder( - stream: player.stream.playing, - initialData: player.state.playing, - builder: (context, playingSnapshot) { - final playing = playingSnapshot.data ?? false; - return StreamBuilder( - stream: player.stream.duration, - initialData: player.state.duration, - builder: (context, durationSnapshot) { - final duration = durationSnapshot.data ?? Duration.zero; - return StreamBuilder( - stream: player.stream.position, - initialData: player.state.position, - builder: (context, positionSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; - final maxMs = duration.inMilliseconds - .clamp(1, 1 << 31) - .toDouble(); - final valueMs = position.inMilliseconds - .clamp(0, maxMs.toInt()) - .toDouble(); - return LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < 560; - final tiny = constraints.maxWidth < 390; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SliderTheme( - data: SliderTheme.of(context).copyWith( - activeTrackColor: scheme.primary, - inactiveTrackColor: - Colors.white.withValues(alpha: 0.24), - trackHeight: 3, - thumbColor: scheme.primary, - overlayColor: - scheme.primary.withValues(alpha: 0.18), - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 5, - ), - ), - child: Slider( - value: valueMs, - max: maxMs, - onChanged: duration == Duration.zero - ? null - : (value) => player.seek( - Duration( - milliseconds: value.round(), - ), - ), - ), - ), - Row( - children: [ - _RoundControlButton( - tooltip: playing ? 'Pause' : 'Play', - emphasized: true, - onPressed: player.playOrPause, - icon: playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - const SizedBox(width: 8), - Text( - tiny - ? _format(position) - : '${_format(position)} / ${_format(duration)}', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - const Spacer(), - if (!compact) ...[ - _RoundControlButton( - tooltip: 'Back 10s', - onPressed: () => player.seek( - _clampSeek( - position - - const Duration(seconds: 10), - duration, - ), - ), - icon: Icons.replay_10_rounded, - ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: 'Forward 10s', - onPressed: () => player.seek( - _clampSeek( - position + - const Duration(seconds: 10), - duration, - ), - ), - icon: Icons.forward_10_rounded, - ), - const SizedBox(width: 5), - ], - _RoundControlButton( - tooltip: muted ? 'Unmute' : 'Mute', - onPressed: onToggleMute, - icon: muted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: halfVolume - ? 'Normal app volume' - : 'Half app volume', - selected: halfVolume, - onPressed: onToggleHalfVolume, - icon: Icons.volume_down_rounded, - ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: loopVideo - ? 'Disable repeat' - : 'Repeat video', - selected: loopVideo, - onPressed: onToggleLoop, - icon: loopVideo - ? Icons.repeat_one_on_rounded - : Icons.repeat_one_rounded, - ), - const SizedBox(width: 5), - if (!compact) ...[ - _RoundControlButton( - tooltip: coverVideo ? 'Fit' : 'Fill', - selected: coverVideo, - onPressed: onToggleFit, - icon: coverVideo - ? Icons.fit_screen_rounded - : Icons.crop_free_rounded, - ), - const SizedBox(width: 5), - ], - _RoundControlButton( - tooltip: fullscreen - ? 'Exit fullscreen' - : 'Fullscreen', - onPressed: onFullscreen, - icon: fullscreen - ? Icons.fullscreen_exit_rounded - : Icons.fullscreen_rounded, - ), - ], - ), - ], - ); - }, - ); - }, - ); - }, - ); - }, - ), - ), - ), - ), - ); - } - - String _format(Duration duration) { - final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); - final hours = duration.inHours; - if (hours > 0) return '$hours:$minutes:$seconds'; - return '$minutes:$seconds'; - } - - Duration _clampSeek(Duration position, Duration duration) { - if (position < Duration.zero) return Duration.zero; - if (duration > Duration.zero && position > duration) return duration; - return position; - } -} +import 'dart:async'; +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:webview_windows/webview_windows.dart'; + +import '../../../../app/motion.dart'; +import '../../../../backend/backend.dart'; + +final Map _playbackMemory = + {}; + +class PostMediaViewer extends StatefulWidget { + const PostMediaViewer({ + required this.post, + this.fullscreen = false, + this.initialPosition = Duration.zero, + this.autoplay = false, + this.initialLoop = false, + this.initialMuted = false, + this.initialCoverVideo = false, + this.initialHalfVolume = false, + this.qualityMode = MediaQualityMode.auto, + this.onPlaybackSnapshot, + this.onPlaybackPreferencesChanged, + super.key, + }); + + final Post post; + final bool fullscreen; + final Duration initialPosition; + final bool autoplay; + final bool initialLoop; + final bool initialMuted; + final bool initialCoverVideo; + final bool initialHalfVolume; + final MediaQualityMode qualityMode; + final ValueChanged? onPlaybackSnapshot; + final ValueChanged? onPlaybackPreferencesChanged; + + @override + State createState() => _PostMediaViewerState(); +} + +class _PostMediaViewerState extends State + with AutomaticKeepAliveClientMixin { + Player? _player; + VideoController? _controller; + late List _imageUrls; + late List _videoUrls; + int _imageIndex = 0; + int _videoIndex = 0; + bool _controlsVisible = true; + bool _inFullscreen = false; + late bool _coverVideo; + late bool _muted; + late bool _loopVideo; + late bool _halfVolume; + String? _videoError; + Timer? _hideTimer; + StreamSubscription? _errorSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _playingSubscription; + + @override + void initState() { + super.initState(); + _coverVideo = widget.initialCoverVideo; + _muted = widget.initialMuted; + _loopVideo = widget.initialLoop; + _halfVolume = widget.initialHalfVolume; + _imageUrls = _buildImageUrls(widget.post); + _videoUrls = _buildVideoUrls(widget.post); + if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { + _initializeVideo(); + } + } + + @override + void didUpdateWidget(covariant PostMediaViewer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.post.cacheKey != widget.post.cacheKey) { + _disposeVideo(); + _imageIndex = 0; + _videoIndex = 0; + _videoError = null; + _imageUrls = _buildImageUrls(widget.post); + _videoUrls = _buildVideoUrls(widget.post); + if (_isVideo(widget.post) && _videoUrls.isNotEmpty) { + _initializeVideo(); + } + } + } + + @override + void dispose() { + _disposeVideo(); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + if (_isSwf(widget.post)) { + return _SwfMediaViewer( + post: widget.post, + url: _swfUrl(widget.post), + fullscreen: widget.fullscreen, + ); + } + + if (_controller != null) { + if (_inFullscreen) { + return ColoredBox( + color: Colors.black, + child: AspectRatio( + aspectRatio: widget.post.width > 0 && widget.post.height > 0 + ? (widget.post.width / widget.post.height).clamp(0.35, 2.4) + : 16 / 9, + child: const Center( + child: Icon(Icons.fullscreen_rounded, color: Colors.white38), + ), + ), + ); + } + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), + }, + child: Actions( + actions: { + _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( + onInvoke: (_) { + _togglePlay(); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: _VideoSurface( + player: _player!, + controller: _controller!, + aspectRatio: widget.post.width > 0 && widget.post.height > 0 + ? widget.post.width / widget.post.height + : 16 / 9, + controlsVisible: _controlsVisible, + coverVideo: _coverVideo, + muted: _muted, + loopVideo: _loopVideo, + halfVolume: _halfVolume, + fullscreen: widget.fullscreen, + errorMessage: _videoError, + onInteract: _showControls, + onRetry: _retryVideo, + onToggleFit: () { + setState(() => _coverVideo = !_coverVideo); + _emitPlaybackPreferences(); + _showControls(); + }, + onToggleMute: () async { + final nextMuted = !_muted; + setState(() => _muted = nextMuted); + await _applyVolume(); + _emitPlaybackPreferences(); + _showControls(); + }, + onToggleHalfVolume: () async { + setState(() { + _halfVolume = !_halfVolume; + if (_halfVolume) _muted = false; + }); + await _applyVolume(); + _emitPlaybackPreferences(); + _showControls(); + }, + onToggleLoop: () async { + final nextLoop = !_loopVideo; + setState(() => _loopVideo = nextLoop); + await _player!.setPlaylistMode( + nextLoop ? PlaylistMode.single : PlaylistMode.none, + ); + _emitPlaybackPreferences(); + _showControls(); + }, + onFullscreen: widget.fullscreen + ? () => Navigator.of(context, rootNavigator: true) + .pop(_snapshot()) + : () => _openFullscreen(context), + ), + ), + ), + ); + } + + final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; + final image = CachedNetworkImage( + key: ValueKey(url), + imageUrl: url, + httpHeaders: _headersFor(widget.post), + fit: BoxFit.contain, + placeholder: (context, url) => + const Center(child: CircularProgressIndicator()), + errorWidget: (context, url, error) { + if (_imageIndex < _imageUrls.length - 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _imageIndex++); + }); + return const Center(child: CircularProgressIndicator()); + } + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.broken_image_rounded, size: 48), + SizedBox(height: 8), + Text('Could not load image'), + ], + ), + ); + }, + ); + final allowPinchZoom = MediaQuery.sizeOf(context).width < 700; + return allowPinchZoom ? _ZoomableImage(child: image) : image; + } + + void _initializeVideo() { + MediaKit.ensureInitialized(); + _player = Player(); + _controller = VideoController(_player!); + _applyVolume(); + _player!.setPlaylistMode( + _loopVideo ? PlaylistMode.single : PlaylistMode.none, + ); + _errorSubscription = _player!.stream.error.listen((message) { + if (!mounted) return; + if (_videoIndex < _videoUrls.length - 1) { + _videoIndex++; + _openVideo(play: false); + return; + } + setState(() => _videoError = message); + _showControls(); + }); + _positionSubscription = _player!.stream.position.listen((_) { + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); + }); + _playingSubscription = _player!.stream.playing.listen((_) { + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); + }); + _openVideo(play: widget.autoplay); + _scheduleControlsHide(); + } + + Future _openVideo({required bool play}) async { + final player = _player; + if (player == null || _videoUrls.isEmpty) return; + final remembered = _playbackMemory[widget.post.cacheKey]; + final initialPosition = widget.initialPosition > Duration.zero + ? widget.initialPosition + : remembered?.position ?? Duration.zero; + setState(() => _videoError = null); + await player.open( + Media( + _videoUrls[_videoIndex], + httpHeaders: _headersFor(widget.post), + ), + play: false, + ); + if (initialPosition > Duration.zero) { + await player.seek(initialPosition); + } + if (play) await player.play(); + } + + Future _retryVideo() async { + final player = _player; + if (player == null) return; + setState(() { + _videoError = null; + _videoIndex = 0; + _controlsVisible = true; + }); + await player.stop(); + await _openVideo(play: true); + _showControls(); + } + + void _disposeVideo() { + _hideTimer?.cancel(); + _hideTimer = null; + _errorSubscription?.cancel(); + _errorSubscription = null; + _positionSubscription?.cancel(); + _positionSubscription = null; + _playingSubscription?.cancel(); + _playingSubscription = null; + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); + _player?.dispose(); + _player = null; + _controller = null; + } + + List _buildImageUrls(Post post) { + return MediaUrlSelector.details(post, mode: widget.qualityMode); + } + + List _buildVideoUrls(Post post) { + return MediaUrlSelector.video(post); + } + + bool _isVideo(Post post) { + final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); + return value.contains('video') || + value.contains('.webm') || + value.contains('.mp4') || + value.contains('.mov'); + } + + bool _isSwf(Post post) { + final value = + '${post.fileType} ${post.fileUrl} ${post.sampleUrl} ${post.source ?? ''}' + .toLowerCase(); + return value.contains('swf') || value.contains('.swf'); + } + + String _swfUrl(Post post) { + for (final url in [post.fileUrl, post.sampleUrl, post.source ?? '']) { + final value = url.trim(); + if (value.toLowerCase().contains('.swf')) return value; + } + return post.fileUrl.trim(); + } + + Map _headersFor(Post post) { + return { + 'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser', + 'Accept': '*/*', + if (post.providerName.toLowerCase().contains('gelbooru') || + post.fileUrl.contains('gelbooru.com') || + post.sampleUrl.contains('gelbooru.com') || + post.previewUrl.contains('gelbooru.com')) + 'Referer': 'https://gelbooru.com/', + if (post.providerName.toLowerCase().contains('rule34') || + post.fileUrl.contains('rule34') || + post.sampleUrl.contains('rule34') || + post.previewUrl.contains('rule34')) + 'Referer': 'https://rule34.xxx/', + }; + } + + void _showControls() { + if (!mounted) return; + setState(() => _controlsVisible = true); + _scheduleControlsHide(); + } + + void _scheduleControlsHide() { + _hideTimer?.cancel(); + _hideTimer = Timer(const Duration(seconds: 3), () { + if (mounted && _videoError == null) { + setState(() => _controlsVisible = false); + } + }); + } + + Future _openFullscreen(BuildContext context) async { + final player = _player; + final controller = _controller; + if (player == null || controller == null) return; + setState(() => _inFullscreen = true); + if (!context.mounted) return; + final result = await Navigator.of(context, rootNavigator: true) + .push( + PageRouteBuilder( + opaque: true, + pageBuilder: (_, __, ___) => _FullscreenVideoPage( + player: player, + controller: controller, + aspectRatio: widget.post.width > 0 && widget.post.height > 0 + ? widget.post.width / widget.post.height + : 16 / 9, + loopVideo: _loopVideo, + muted: _muted, + halfVolume: _halfVolume, + coverVideo: _coverVideo, + errorMessage: _videoError, + onRetry: _retryVideo, + onChanged: (snapshot) { + unawaited(_applyPlaybackSnapshot(snapshot)); + }, + ), + transitionsBuilder: (_, animation, __, child) => FadeTransition( + opacity: animation, + child: child, + ), + ), + ); + if (mounted) setState(() => _inFullscreen = false); + if (result != null) { + await _applyPlaybackSnapshot(result); + return; + } + } + + Future _applyPlaybackSnapshot(VideoPlaybackSnapshot snapshot) async { + if (!mounted) return; + setState(() { + _muted = snapshot.muted; + _loopVideo = snapshot.loopVideo; + _coverVideo = snapshot.coverVideo; + _halfVolume = snapshot.halfVolume; + }); + await _applyVolume(); + await _player?.setPlaylistMode( + _loopVideo ? PlaylistMode.single : PlaylistMode.none, + ); + _emitPlaybackPreferences(); + } + + VideoPlaybackSnapshot _snapshot() { + final player = _player; + return VideoPlaybackSnapshot( + position: player?.state.position ?? Duration.zero, + playing: player?.state.playing ?? false, + muted: _muted, + halfVolume: _halfVolume, + loopVideo: _loopVideo, + coverVideo: _coverVideo, + ); + } + + Future _applyVolume() async { + await _player?.setVolume(_muted + ? 0 + : _halfVolume + ? 50 + : 100); + } + + void _emitPlaybackPreferences() { + final snapshot = _snapshot(); + _playbackMemory[widget.post.cacheKey] = snapshot; + widget.onPlaybackSnapshot?.call(snapshot); + widget.onPlaybackPreferencesChanged?.call(snapshot); + } + + Future _togglePlay() async { + final player = _player; + if (player == null) return; + if (player.state.playing) { + await player.pause(); + } else { + await player.play(); + } + _showControls(); + } +} + +class _TogglePlayIntent extends Intent { + const _TogglePlayIntent(); +} + +class _SwfMediaViewer extends StatelessWidget { + const _SwfMediaViewer({ + required this.post, + required this.url, + required this.fullscreen, + }); + + final Post post; + final String url; + final bool fullscreen; + + @override + Widget build(BuildContext context) { + if (url.isEmpty) { + return const Center(child: Text('SWF URL is empty')); + } + if (Platform.isWindows) { + return _WindowsRuffleViewer(url: url, fullscreen: fullscreen); + } + return _SwfUnsupportedPanel(url: url); + } +} + +class _WindowsRuffleViewer extends StatefulWidget { + const _WindowsRuffleViewer({ + required this.url, + required this.fullscreen, + }); + + final String url; + final bool fullscreen; + + @override + State<_WindowsRuffleViewer> createState() => _WindowsRuffleViewerState(); +} + +class _WindowsRuffleViewerState extends State<_WindowsRuffleViewer> { + final _controller = WebviewController(); + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _initialize(); + } + + @override + void didUpdateWidget(covariant _WindowsRuffleViewer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.url != widget.url) { + _load(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final child = ClipRRect( + borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10), + child: Stack( + fit: StackFit.expand, + children: [ + if (_controller.value.isInitialized) Webview(_controller), + if (_loading) + const ColoredBox( + color: Colors.black, + child: Center(child: CircularProgressIndicator()), + ), + if (_error != null) + _SwfErrorPanel( + message: _error!, + url: widget.url, + onRetry: _load, + ), + ], + ), + ); + if (widget.fullscreen) return child; + return AspectRatio(aspectRatio: 4 / 3, child: child); + } + + Future _initialize() async { + try { + await _controller.initialize(); + _controller.loadingState.listen((state) { + if (!mounted) return; + setState(() => _loading = state == LoadingState.loading); + }); + _controller.onLoadError.listen((error) { + if (!mounted) return; + setState(() => _error = 'Ruffle WebView error: $error'); + }); + await _load(); + } catch (error) { + if (!mounted) return; + setState(() { + _loading = false; + _error = + 'Could not start Windows WebView2. Install WebView2 Runtime or open original.'; + }); + } + } + + Future _load() async { + if (!_controller.value.isInitialized) return; + setState(() { + _loading = true; + _error = null; + }); + try { + await _controller.loadStringContent(_ruffleHtml(widget.url)); + } catch (error) { + if (!mounted) return; + setState(() { + _loading = false; + _error = 'Could not load Ruffle viewer: $error'; + }); + } + } + + String _ruffleHtml(String swfUrl) { + final escapedUrl = _htmlEscape(swfUrl); + return ''' + + + + + + + + + + +
+ +
+ + +'''; + } + + String _htmlEscape(String value) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>'); + } +} + +class _SwfUnsupportedPanel extends StatelessWidget { + const _SwfUnsupportedPanel({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + return _SwfErrorPanel( + message: Platform.isAndroid + ? 'Flash/SWF playback is desktop only.' + : 'SWF playback is available on Windows only in this build.', + url: url, + ); + } +} + +class _SwfErrorPanel extends StatelessWidget { + const _SwfErrorPanel({ + required this.message, + required this.url, + this.onRetry, + }); + + final String message; + final String url; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return ColoredBox( + color: Colors.black, + child: Center( + child: Padding( + padding: const EdgeInsets.all(22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.extension_rounded, + color: Colors.white, size: 48), + const SizedBox(height: 12), + Text( + 'SWF / Flash', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: Colors.white), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + if (onRetry != null) + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Retry'), + ), + FilledButton.tonalIcon( + onPressed: () => launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ), + icon: const Icon(Icons.open_in_new_rounded), + label: const Text('Open original'), + ), + FilledButton.tonalIcon( + style: FilledButton.styleFrom( + backgroundColor: + scheme.primaryContainer.withValues(alpha: 0.82), + ), + onPressed: () => launchUrl( + Uri.parse( + 'https://ruffle.rs/demo/?url=${Uri.encodeComponent(url)}', + ), + mode: LaunchMode.externalApplication, + ), + icon: const Icon(Icons.play_circle_outline_rounded), + label: const Text('Open in Ruffle'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class VideoPlaybackSnapshot { + const VideoPlaybackSnapshot({ + required this.position, + required this.playing, + required this.muted, + required this.halfVolume, + required this.loopVideo, + required this.coverVideo, + }); + + final Duration position; + final bool playing; + final bool muted; + final bool halfVolume; + final bool loopVideo; + final bool coverVideo; +} + +class _FullscreenVideoPage extends StatefulWidget { + const _FullscreenVideoPage({ + required this.player, + required this.controller, + required this.aspectRatio, + required this.loopVideo, + required this.muted, + required this.halfVolume, + required this.coverVideo, + required this.errorMessage, + required this.onRetry, + required this.onChanged, + }); + + final Player player; + final VideoController controller; + final double aspectRatio; + final bool loopVideo; + final bool muted; + final bool halfVolume; + final bool coverVideo; + final String? errorMessage; + final VoidCallback onRetry; + final ValueChanged onChanged; + + @override + State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState(); +} + +class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { + bool _controlsVisible = true; + late bool _coverVideo; + late bool _muted; + late bool _loopVideo; + late bool _halfVolume; + Timer? _hideTimer; + + @override + void initState() { + super.initState(); + _coverVideo = widget.coverVideo; + _muted = widget.muted; + _loopVideo = widget.loopVideo; + _halfVolume = widget.halfVolume; + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + DeviceOrientation.portraitUp, + ]); + _scheduleControlsHide(); + } + + @override + void dispose() { + _hideTimer?.cancel(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + Navigator.of(context, rootNavigator: true).pop(_snapshot()); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(), + LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), + }, + child: Actions( + actions: { + _CloseVideoIntent: CallbackAction<_CloseVideoIntent>( + onInvoke: (_) { + Navigator.of(context, rootNavigator: true).pop(_snapshot()); + return null; + }, + ), + _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( + onInvoke: (_) { + widget.player.playOrPause(); + _showControls(); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: Stack( + children: [ + Positioned.fill( + child: _VideoSurface( + player: widget.player, + controller: widget.controller, + aspectRatio: widget.aspectRatio, + controlsVisible: _controlsVisible, + coverVideo: _coverVideo, + muted: _muted, + loopVideo: _loopVideo, + halfVolume: _halfVolume, + fullscreen: true, + errorMessage: widget.errorMessage, + onInteract: _showControls, + onRetry: widget.onRetry, + onToggleFit: () { + setState(() => _coverVideo = !_coverVideo); + widget.onChanged(_snapshot()); + _showControls(); + }, + onToggleMute: () async { + setState(() => _muted = !_muted); + await _applyVolume(); + widget.onChanged(_snapshot()); + _showControls(); + }, + onToggleHalfVolume: () async { + setState(() { + _halfVolume = !_halfVolume; + if (_halfVolume) _muted = false; + }); + await _applyVolume(); + widget.onChanged(_snapshot()); + _showControls(); + }, + onToggleLoop: () async { + setState(() => _loopVideo = !_loopVideo); + await widget.player.setPlaylistMode( + _loopVideo ? PlaylistMode.single : PlaylistMode.none, + ); + widget.onChanged(_snapshot()); + _showControls(); + }, + onFullscreen: () => Navigator.of( + context, + rootNavigator: true, + ).pop(_snapshot()), + ), + ), + Positioned( + top: 12, + right: 12, + child: SafeArea( + child: IconButton.filledTonal( + tooltip: 'Close fullscreen', + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(_snapshot()), + icon: const Icon(Icons.close_rounded), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + void _showControls() { + if (!mounted) return; + setState(() => _controlsVisible = true); + _scheduleControlsHide(); + } + + void _scheduleControlsHide() { + _hideTimer?.cancel(); + _hideTimer = Timer(const Duration(seconds: 3), () { + if (mounted && widget.errorMessage == null) { + setState(() => _controlsVisible = false); + } + }); + } + + Future _applyVolume() async { + await widget.player.setVolume(_muted + ? 0 + : _halfVolume + ? 50 + : 100); + } + + VideoPlaybackSnapshot _snapshot() { + return VideoPlaybackSnapshot( + position: widget.player.state.position, + playing: widget.player.state.playing, + muted: _muted, + halfVolume: _halfVolume, + loopVideo: _loopVideo, + coverVideo: _coverVideo, + ); + } +} + +class _CloseVideoIntent extends Intent { + const _CloseVideoIntent(); +} + +class _VideoSurface extends StatelessWidget { + const _VideoSurface({ + required this.player, + required this.controller, + required this.aspectRatio, + required this.controlsVisible, + required this.coverVideo, + required this.muted, + required this.halfVolume, + required this.loopVideo, + required this.fullscreen, + required this.errorMessage, + required this.onInteract, + required this.onRetry, + required this.onToggleFit, + required this.onToggleMute, + required this.onToggleHalfVolume, + required this.onToggleLoop, + required this.onFullscreen, + }); + + final Player player; + final VideoController controller; + final double aspectRatio; + final bool controlsVisible; + final bool coverVideo; + final bool muted; + final bool halfVolume; + final bool loopVideo; + final bool fullscreen; + final String? errorMessage; + final VoidCallback onInteract; + final VoidCallback onRetry; + final VoidCallback onToggleFit; + final VoidCallback onToggleMute; + final VoidCallback onToggleHalfVolume; + final VoidCallback onToggleLoop; + final VoidCallback onFullscreen; + + @override + Widget build(BuildContext context) { + final child = Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: Colors.black, + child: Video( + controller: controller, + fit: coverVideo ? BoxFit.cover : BoxFit.contain, + controls: null, + ), + ), + StreamBuilder( + stream: player.stream.buffering, + initialData: player.state.buffering, + builder: (context, snapshot) { + if (snapshot.data != true || errorMessage != null) { + return const SizedBox.shrink(); + } + return const Center(child: CircularProgressIndicator()); + }, + ), + if (errorMessage != null) + VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), + if (errorMessage == null) + StreamBuilder( + stream: player.stream.playing, + initialData: player.state.playing, + builder: (context, snapshot) { + final playing = snapshot.data ?? false; + return AnimatedScale( + scale: controlsVisible || !playing ? 1 : 0.85, + duration: AppMotion.duration(context, 160), + child: AnimatedOpacity( + opacity: controlsVisible || !playing ? 1 : 0, + duration: AppMotion.duration(context, 160), + child: Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.46), + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Colors.black45, + blurRadius: 24, + offset: Offset(0, 10), + ), + ], + ), + child: IconButton( + tooltip: playing ? 'Pause' : 'Play', + iconSize: fullscreen ? 58 : 44, + color: Colors.white, + onPressed: () { + player.playOrPause(); + onInteract(); + }, + icon: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + ), + ), + ), + ), + ); + }, + ), + IgnorePointer( + ignoring: !controlsVisible || errorMessage != null, + child: AnimatedOpacity( + opacity: controlsVisible || errorMessage != null ? 1 : 0, + duration: AppMotion.duration(context, 180), + child: _VideoControls( + player: player, + muted: muted, + halfVolume: halfVolume, + loopVideo: loopVideo, + coverVideo: coverVideo, + fullscreen: fullscreen, + onToggleFit: onToggleFit, + onToggleMute: onToggleMute, + onToggleHalfVolume: onToggleHalfVolume, + onToggleLoop: onToggleLoop, + onFullscreen: onFullscreen, + ), + ), + ), + ], + ); + + return MouseRegion( + onHover: (_) => onInteract(), + child: GestureDetector( + onTap: onInteract, + onLongPress: player.playOrPause, + onDoubleTap: onFullscreen, + child: ClipRRect( + borderRadius: BorderRadius.circular(fullscreen ? 0 : 10), + child: fullscreen + ? SizedBox.expand(child: child) + : AspectRatio( + aspectRatio: aspectRatio.clamp(0.35, 2.4), + child: child, + ), + ), + ), + ); + } +} + +class VideoErrorOverlay extends StatelessWidget { + const VideoErrorOverlay({ + required this.message, + required this.onRetry, + super.key, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.black.withValues(alpha: 0.62), + child: Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline_rounded, + color: Colors.white, size: 42), + const SizedBox(height: 10), + Text( + 'Could not load video', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Colors.white), + ), + const SizedBox(height: 6), + Text( + message, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } +} + +class _ZoomableImage extends StatefulWidget { + const _ZoomableImage({required this.child}); + + final Widget child; + + @override + State<_ZoomableImage> createState() => _ZoomableImageState(); +} + +class _ZoomableImageState extends State<_ZoomableImage> { + final _controller = TransformationController(); + TapDownDetails? _doubleTapDetails; + bool _zoomed = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onDoubleTapDown: (details) => _doubleTapDetails = details, + onDoubleTap: _toggleZoom, + child: InteractiveViewer( + transformationController: _controller, + minScale: 1, + maxScale: 5, + panEnabled: _zoomed, + scaleEnabled: true, + clipBehavior: Clip.none, + onInteractionEnd: (_) { + final scale = _controller.value.getMaxScaleOnAxis(); + if (mounted) setState(() => _zoomed = scale > 1.03); + }, + child: widget.child, + ), + ); + } + + void _toggleZoom() { + final tap = _doubleTapDetails?.localPosition ?? Offset.zero; + if (_zoomed) { + _controller.value = Matrix4.identity(); + setState(() => _zoomed = false); + return; + } + _controller.value = Matrix4.identity() + ..translateByDouble(-tap.dx * 1.3, -tap.dy * 1.3, 0, 1) + ..scaleByDouble(2.3, 2.3, 1, 1); + setState(() => _zoomed = true); + } +} + +class _RoundControlButton extends StatelessWidget { + const _RoundControlButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + this.emphasized = false, + this.selected = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + final bool emphasized; + final bool selected; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final background = emphasized + ? scheme.primary + : selected + ? scheme.primaryContainer.withValues(alpha: 0.96) + : Colors.white.withValues(alpha: 0.13); + final foreground = emphasized + ? scheme.onPrimary + : selected + ? scheme.onPrimaryContainer + : Colors.white; + return Tooltip( + message: tooltip, + child: InkResponse( + onTap: onPressed, + radius: emphasized ? 23 : 21, + child: AnimatedContainer( + duration: AppMotion.duration(context, 140), + width: emphasized ? 38 : 34, + height: emphasized ? 38 : 34, + decoration: BoxDecoration( + color: background, + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Icon(icon, color: foreground, size: emphasized ? 24 : 20), + ), + ), + ); + } +} + +class _VideoControls extends StatelessWidget { + const _VideoControls({ + required this.player, + required this.muted, + required this.halfVolume, + required this.loopVideo, + required this.coverVideo, + required this.fullscreen, + required this.onToggleFit, + required this.onToggleMute, + required this.onToggleHalfVolume, + required this.onToggleLoop, + required this.onFullscreen, + }); + + final Player player; + final bool muted; + final bool halfVolume; + final bool loopVideo; + final bool coverVideo; + final bool fullscreen; + final VoidCallback onToggleFit; + final VoidCallback onToggleMute; + final VoidCallback onToggleHalfVolume; + final VoidCallback onToggleLoop; + final VoidCallback onFullscreen; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Align( + alignment: Alignment.bottomCenter, + child: SafeArea( + minimum: EdgeInsets.fromLTRB( + fullscreen ? 14 : 8, + 0, + fullscreen ? 14 : 8, + fullscreen ? 10 : 8, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.58), + borderRadius: BorderRadius.circular(fullscreen ? 18 : 14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), + child: StreamBuilder( + stream: player.stream.playing, + initialData: player.state.playing, + builder: (context, playingSnapshot) { + final playing = playingSnapshot.data ?? false; + return StreamBuilder( + stream: player.stream.duration, + initialData: player.state.duration, + builder: (context, durationSnapshot) { + final duration = durationSnapshot.data ?? Duration.zero; + return StreamBuilder( + stream: player.stream.position, + initialData: player.state.position, + builder: (context, positionSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final maxMs = duration.inMilliseconds + .clamp(1, 1 << 31) + .toDouble(); + final valueMs = position.inMilliseconds + .clamp(0, maxMs.toInt()) + .toDouble(); + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 560; + final tiny = constraints.maxWidth < 390; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: scheme.primary, + inactiveTrackColor: + Colors.white.withValues(alpha: 0.24), + trackHeight: 3, + thumbColor: scheme.primary, + overlayColor: + scheme.primary.withValues(alpha: 0.18), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 5, + ), + ), + child: Slider( + value: valueMs, + max: maxMs, + onChanged: duration == Duration.zero + ? null + : (value) => player.seek( + Duration( + milliseconds: value.round(), + ), + ), + ), + ), + Row( + children: [ + _RoundControlButton( + tooltip: playing ? 'Pause' : 'Play', + emphasized: true, + onPressed: player.playOrPause, + icon: playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + const SizedBox(width: 8), + Text( + tiny + ? _format(position) + : '${_format(position)} / ${_format(duration)}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + if (!compact) ...[ + _RoundControlButton( + tooltip: 'Back 10s', + onPressed: () => player.seek( + _clampSeek( + position - + const Duration(seconds: 10), + duration, + ), + ), + icon: Icons.replay_10_rounded, + ), + const SizedBox(width: 5), + _RoundControlButton( + tooltip: 'Forward 10s', + onPressed: () => player.seek( + _clampSeek( + position + + const Duration(seconds: 10), + duration, + ), + ), + icon: Icons.forward_10_rounded, + ), + const SizedBox(width: 5), + ], + _RoundControlButton( + tooltip: muted ? 'Unmute' : 'Mute', + onPressed: onToggleMute, + icon: muted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + ), + const SizedBox(width: 5), + _RoundControlButton( + tooltip: halfVolume + ? 'Normal app volume' + : 'Half app volume', + selected: halfVolume, + onPressed: onToggleHalfVolume, + icon: Icons.volume_down_rounded, + ), + const SizedBox(width: 5), + _RoundControlButton( + tooltip: loopVideo + ? 'Disable repeat' + : 'Repeat video', + selected: loopVideo, + onPressed: onToggleLoop, + icon: loopVideo + ? Icons.repeat_one_on_rounded + : Icons.repeat_one_rounded, + ), + const SizedBox(width: 5), + if (!compact) ...[ + _RoundControlButton( + tooltip: coverVideo ? 'Fit' : 'Fill', + selected: coverVideo, + onPressed: onToggleFit, + icon: coverVideo + ? Icons.fit_screen_rounded + : Icons.crop_free_rounded, + ), + const SizedBox(width: 5), + ], + _RoundControlButton( + tooltip: fullscreen + ? 'Exit fullscreen' + : 'Fullscreen', + onPressed: onFullscreen, + icon: fullscreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, + ), + ], + ), + ], + ); + }, + ); + }, + ); + }, + ); + }, + ), + ), + ), + ), + ); + } + + String _format(Duration duration) { + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + final hours = duration.inHours; + if (hours > 0) return '$hours:$minutes:$seconds'; + return '$minutes:$seconds'; + } + + Duration _clampSeek(Duration position, Duration duration) { + if (position < Duration.zero) return Duration.zero; + if (duration > Duration.zero && position > duration) return duration; + return position; + } +} diff --git a/linux/.gitignore b/linux/.gitignore index d3896c9..c7ea17f 100644 --- a/linux/.gitignore +++ b/linux/.gitignore @@ -1 +1 @@ -flutter/ephemeral +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index a7f4283..49becf7 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -1,128 +1,128 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "gel_rule_app") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.gel_rule_app") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "gel_rule_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.gel_rule_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt index d5bd016..27860e8 100644 --- a/linux/flutter/CMakeLists.txt +++ b/linux/flutter/CMakeLists.txt @@ -1,88 +1,88 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index e97dabc..7ed6f3e 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -1,26 +1,26 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the application ID. -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc index e7c5c54..4340ffc 100644 --- a/linux/runner/main.cc +++ b/linux/runner/main.cc @@ -1,6 +1,6 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} +#include "my_application.h" + +int main(int argc, char** argv) { + 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 172a86b..8332c30 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,148 +1,148 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView* view) { - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); -} - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); gtk_header_bar_set_title(header_bar, "Lunaris"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { gtk_window_set_title(window, "Lunaris"); - } - - gtk_window_set_default_size(window, 1280, 720); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 - // for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), - self); - gtk_widget_realize(GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h index db16367..c4c4a71 100644 --- a/linux/runner/my_application.h +++ b/linux/runner/my_application.h @@ -1,21 +1,21 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, - my_application, - MY, - APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/pubspec.lock b/pubspec.lock index 19c6cc0..895bf85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -165,10 +165,10 @@ packages: dependency: transitive description: name: code_assets - sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f + sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" code_builder: dependency: transitive description: @@ -289,6 +289,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: @@ -675,10 +683,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -731,18 +739,18 @@ packages: dependency: transitive description: name: package_info_plus - sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" url: "https://pub.dev" source: hosted - version: "9.0.1" + version: "10.1.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "4.1.0" path: dependency: "direct main" description: @@ -1056,10 +1064,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" time: dependency: transitive description: @@ -1200,10 +1208,10 @@ packages: dependency: transitive description: name: wakelock_plus - sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.6.1" wakelock_plus_platform_interface: dependency: transitive description: @@ -1256,10 +1264,10 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.3.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a62c54d..24ecb3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 2.0.1+21 +version: 2.0.1+22 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/isar_integration_test.dart b/test/backend/isar_integration_test.dart index dee3cfe..ea749cf 100644 --- a/test/backend/isar_integration_test.dart +++ b/test/backend/isar_integration_test.dart @@ -1,3 +1,4 @@ +import 'dart:ffi' show Abi; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -15,6 +16,7 @@ import 'package:gel_rule_app/core/cache/cache_service.dart'; import 'package:gel_rule_app/core/database/app_database.dart'; import 'package:gel_rule_app/core/database/database_service.dart'; import 'package:gel_rule_app/core/utils/result.dart'; +import 'package:isar/isar.dart'; void main() { late Directory directory; @@ -24,6 +26,14 @@ void main() { late File copiedIsarDll; setUpAll(() async { + if (Platform.isLinux) { + await Isar.initializeIsarCore( + libraries: { + Abi.linuxX64: + '${Directory.current.path}/third_party/isar_flutter_libs/linux/libisar.so', + }, + ); + } copiedIsarDll = File('${Directory.current.path}${Platform.pathSeparator}isar.dll'); if (Platform.isWindows && !copiedIsarDll.existsSync()) { diff --git a/test/shared/tag_input_search_bar_test.dart b/test/shared/tag_input_search_bar_test.dart index 6dd16fc..f75d7bb 100644 --- a/test/shared/tag_input_search_bar_test.dart +++ b/test/shared/tag_input_search_bar_test.dart @@ -57,6 +57,48 @@ void main() { expect(changed, 'video'); }); + testWidgets('clear button removes all committed tags', (tester) async { + String? changed; + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'touhou video', + onSubmitted: (_) {}, + onChanged: (value) { + changed = value; + }, + ), + )); + + await tester.tap(find.byTooltip('Clear')); + await tester.pump(); + + expect(find.text('touhou'), findsNothing); + expect(find.text('video'), findsNothing); + expect(changed, ''); + }); + + testWidgets('external initial value replaces committed tags', (tester) async { + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'old_tag', + onSubmitted: (_) {}, + ), + )); + + expect(find.text('old_tag'), findsOneWidget); + + await tester.pumpWidget(_Harness( + child: TagInputSearchBar( + initialValue: 'new_tag', + onSubmitted: (_) {}, + ), + )); + await tester.pump(); + + expect(find.text('old_tag'), findsNothing); + expect(find.text('new_tag'), findsOneWidget); + }); + testWidgets('suggestion appends active tag as a chip', (tester) async { String? applied; await tester.pumpWidget(_Harness( diff --git a/windows/.gitignore b/windows/.gitignore index d492d0d..ec4098a 100644 --- a/windows/.gitignore +++ b/windows/.gitignore @@ -1,17 +1,17 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 3351b18..efb3540 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,109 +1,109 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(gel_rule_app LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "gel_rule_app") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(gel_rule_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "gel_rule_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + # Use Unicode for all projects. add_definitions(-DUNICODE -D_UNICODE) add_definitions(-D_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT OR CMAKE_INSTALL_PREFIX MATCHES "Program Files") set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index 903f489..efb62eb 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -1,109 +1,109 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# Set fallback configurations for older versions of the flutter tool. -if (NOT DEFINED FLUTTER_TARGET_PLATFORM) - set(FLUTTER_TARGET_PLATFORM "windows-x64") -endif() - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 394917c..2041a04 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -1,40 +1,40 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the build version. -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index d1062c9..cb7dab0 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -1,121 +1,121 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD -#else -#define VERSION_AS_NUMBER 1,0,0,0 -#endif - -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.example" "\0" +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" VALUE "FileDescription", "Lunaris" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "Lunaris" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" - VALUE "OriginalFilename", "gel_rule_app.exe" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "gel_rule_app.exe" "\0" VALUE "ProductName", "Lunaris" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 955ee30..c819cb0 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,71 +1,71 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); - }); - - // Flutter can complete the first frame before the "show window" callback is - // registered. The following call ensures a frame is pending to ensure the - // window is shown. It is a no-op if the first frame hasn't completed yet. - flutter_controller_->ForceRedraw(); - - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 6da0652..28c2383 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -1,33 +1,33 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 1d7c3dc..438bc9b 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -1,43 +1,43 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); if (!window.Create(L"Lunaris", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h index 66a65d1..ddc7f3e 100644 --- a/windows/runner/resource.h +++ b/windows/runner/resource.h @@ -1,16 +1,16 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest index 153653e..4b962bb 100644 --- a/windows/runner/runner.exe.manifest +++ b/windows/runner/runner.exe.manifest @@ -1,14 +1,14 @@ - - - - - PerMonitorV2 - - - - - - - - - + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp index 3a0b465..259d85b 100644 --- a/windows/runner/utils.cpp +++ b/windows/runner/utils.cpp @@ -1,65 +1,65 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - unsigned int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr) - -1; // remove the trailing null character - int input_length = (int)wcslen(utf16_string); - std::string utf8_string; - if (target_length == 0 || target_length > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h index 3879d54..3f0e05c 100644 --- a/windows/runner/utils.h +++ b/windows/runner/utils.h @@ -1,19 +1,19 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index 60608d0..b5ba2a0 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -1,288 +1,288 @@ -#include "win32_window.h" - -#include -#include - -#include "resource.h" - -namespace { - -/// Window attribute that enables dark mode window decorations. -/// -/// Redefined in case the developer's machine has a Windows SDK older than -/// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute -#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE -#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 -#endif - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -/// Registry key for app theme preference. -/// -/// A value of 0 indicates apps should use dark mode. A non-zero or missing -/// value indicates apps should use light mode. -constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - } - FreeLibrary(user32_module); -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - UpdateTheme(window); - - return OnCreate(); -} - -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} - -void Win32Window::UpdateTheme(HWND const window) { - DWORD light_mode; - DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); - - if (result == ERROR_SUCCESS) { - BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); - } -} +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h index e901dde..49b847f 100644 --- a/windows/runner/win32_window.h +++ b/windows/runner/win32_window.h @@ -1,102 +1,102 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates a win32 window with |title| that is positioned and sized using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size this function will scale the inputted width and height as - // as appropriate for the default monitor. The window is invisible until - // |Show| is called. Returns true if the window was created successfully. - bool Create(const std::wstring& title, const Point& origin, const Size& size); - - // Show the current window. Returns true if the window was successfully shown. - bool Show(); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - // Update the window frame's theme to match the system theme. - static void UpdateTheme(HWND const window); - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_