From a6effe208bbc4bfec04baf29809bdd7c57c199c0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 18 Oct 2025 00:05:20 +0200 Subject: [PATCH] squash --- .github/workflows/build.yml | 208 +++ .gitignore | 45 + .metadata | 45 + LICENSE | 674 ++++++++ README.md | 119 ++ analysis_options.yaml | 1 + android/.gitignore | 14 + android/app/build.gradle.kts | 43 + android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 65 + .../flutter_application_1/MainActivity.kt | 5 + .../drawable-hdpi/ic_launcher_foreground.png | Bin 0 -> 5248 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 0 -> 3220 bytes .../res/drawable-v21/launch_background.xml | 12 + .../drawable-xhdpi/ic_launcher_foreground.png | Bin 0 -> 7314 bytes .../ic_launcher_foreground.png | Bin 0 -> 12176 bytes .../ic_launcher_foreground.png | Bin 0 -> 17602 bytes .../main/res/drawable/launch_background.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4750 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2837 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6404 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 10341 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 14403 bytes .../app/src/main/res/values-night/styles.xml | 15 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/styles.xml | 15 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 21 + android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/settings.gradle.kts | 25 + assets/plezy.png | Bin 0 -> 89095 bytes assets/plezy.svg | 1 + assets/plezy_android_foreground.png | Bin 0 -> 60509 bytes devtools_options.yaml | 3 + ios/.gitignore | 34 + ios/Flutter/AppFrameworkInfo.plist | 26 + ios/Flutter/Debug.xcconfig | 2 + ios/Flutter/Release.xcconfig | 2 + ios/Podfile | 43 + ios/Podfile.lock | 73 + ios/Runner.xcodeproj/project.pbxproj | 733 +++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 101 ++ .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + ios/Runner/AppDelegate.swift | 13 + ios/Runner/Base.lproj/LaunchScreen.storyboard | 37 + ios/Runner/Base.lproj/Main.storyboard | 26 + ios/Runner/Info.plist | 60 + ios/Runner/Runner-Bridging-Header.h | 1 + ios/RunnerTests/RunnerTests.swift | 12 + ios/plezy.icon/Assets/plezy-cropped.svg | 1 + ios/plezy.icon/icon.json | 55 + lib/auth/plex_auth.dart | 111 ++ lib/client/plex_client.dart | 663 ++++++++ lib/config/plex_config.dart | 60 + lib/data/iso_639_codes.json | 1 + lib/main.dart | 230 +++ lib/mixins/refreshable.dart | 3 + lib/models/media_container.dart | 31 + lib/models/media_container.g.dart | 31 + lib/models/plex_filter.dart | 55 + lib/models/plex_library.dart | 33 + lib/models/plex_library.g.dart | 32 + lib/models/plex_media_info.dart | 104 ++ lib/models/plex_metadata.dart | 150 ++ lib/models/plex_metadata.g.dart | 70 + lib/models/plex_server_info.dart | 29 + lib/models/plex_server_info.g.dart | 29 + lib/models/plex_user_profile.dart | 83 + lib/screens/auth_screen.dart | 145 ++ lib/screens/discover_screen.dart | 810 ++++++++++ lib/screens/libraries_screen.dart | 712 +++++++++ lib/screens/main_screen.dart | 115 ++ lib/screens/media_detail_screen.dart | 821 ++++++++++ lib/screens/search_screen.dart | 212 +++ lib/screens/season_detail_screen.dart | 371 +++++ lib/screens/server_selection_screen.dart | 157 ++ lib/screens/video_player_screen.dart | 784 +++++++++ lib/services/fullscreen_state_manager.dart | 67 + lib/services/fullscreen_window_delegate.dart | 70 + lib/services/macos_titlebar_service.dart | 61 + lib/services/plex_auth_service.dart | 370 +++++ lib/services/storage_service.dart | 183 +++ lib/utils/app_logger.dart | 24 + lib/utils/language_codes.dart | 104 ++ lib/utils/plex_headers.dart | 37 + lib/widgets/desktop_app_bar.dart | 265 ++++ lib/widgets/media_card.dart | 312 ++++ lib/widgets/media_context_menu.dart | 285 ++++ lib/widgets/plex_video_controls.dart | 1399 +++++++++++++++++ lib/widgets/server_list_tile.dart | 64 + linux/.gitignore | 1 + linux/CMakeLists.txt | 128 ++ linux/flutter/CMakeLists.txt | 88 ++ linux/flutter/generated_plugin_registrant.cc | 35 + linux/flutter/generated_plugin_registrant.h | 15 + linux/flutter/generated_plugins.cmake | 29 + linux/runner/CMakeLists.txt | 26 + linux/runner/main.cc | 6 + linux/runner/my_application.cc | 130 ++ linux/runner/my_application.h | 18 + macos/.gitignore | 7 + macos/Flutter/Flutter-Debug.xcconfig | 2 + macos/Flutter/Flutter-Release.xcconfig | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 34 + macos/Podfile | 42 + macos/Podfile.lock | 91 ++ macos/Runner.xcodeproj/project.pbxproj | 800 ++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 99 ++ .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + macos/Runner/AppDelegate.swift | 13 + macos/Runner/Base.lproj/MainMenu.xib | 343 ++++ macos/Runner/Configs/AppInfo.xcconfig | 14 + macos/Runner/Configs/Debug.xcconfig | 2 + macos/Runner/Configs/Release.xcconfig | 2 + macos/Runner/Configs/Warnings.xcconfig | 13 + macos/Runner/DebugProfile.entitlements | 14 + macos/Runner/Info.plist | 32 + macos/Runner/MainFlutterWindow.swift | 15 + macos/Runner/Release.entitlements | 10 + macos/RunnerTests/RunnerTests.swift | 12 + macos/plezy.icon/Assets/plezy-cropped.svg | 1 + macos/plezy.icon/icon.json | 55 + pubspec.lock | 1179 ++++++++++++++ pubspec.yaml | 58 + windows/.gitignore | 17 + windows/CMakeLists.txt | 108 ++ windows/build-installer.ps1 | 123 ++ windows/flutter/CMakeLists.txt | 109 ++ .../flutter/generated_plugin_registrant.cc | 29 + windows/flutter/generated_plugin_registrant.h | 15 + windows/flutter/generated_plugins.cmake | 29 + windows/runner/CMakeLists.txt | 40 + windows/runner/Runner.rc | 121 ++ windows/runner/flutter_window.cpp | 71 + windows/runner/flutter_window.h | 33 + windows/runner/main.cpp | 43 + windows/runner/resource.h | 16 + windows/runner/resources/app_icon.ico | Bin 0 -> 2859 bytes windows/runner/runner.exe.manifest | 14 + windows/runner/utils.cpp | 65 + windows/runner/utils.h | 19 + windows/runner/win32_window.cpp | 288 ++++ windows/runner/win32_window.h | 102 ++ 152 files changed, 16289 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts create mode 100644 assets/plezy.png create mode 100644 assets/plezy.svg create mode 100644 assets/plezy_android_foreground.png create mode 100644 devtools_options.yaml create mode 100644 ios/.gitignore create mode 100644 ios/Flutter/AppFrameworkInfo.plist create mode 100644 ios/Flutter/Debug.xcconfig create mode 100644 ios/Flutter/Release.xcconfig create mode 100644 ios/Podfile create mode 100644 ios/Podfile.lock create mode 100644 ios/Runner.xcodeproj/project.pbxproj create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner/AppDelegate.swift create mode 100644 ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/Runner/Base.lproj/Main.storyboard create mode 100644 ios/Runner/Info.plist create mode 100644 ios/Runner/Runner-Bridging-Header.h create mode 100644 ios/RunnerTests/RunnerTests.swift create mode 100644 ios/plezy.icon/Assets/plezy-cropped.svg create mode 100644 ios/plezy.icon/icon.json create mode 100644 lib/auth/plex_auth.dart create mode 100644 lib/client/plex_client.dart create mode 100644 lib/config/plex_config.dart create mode 100644 lib/data/iso_639_codes.json create mode 100644 lib/main.dart create mode 100644 lib/mixins/refreshable.dart create mode 100644 lib/models/media_container.dart create mode 100644 lib/models/media_container.g.dart create mode 100644 lib/models/plex_filter.dart create mode 100644 lib/models/plex_library.dart create mode 100644 lib/models/plex_library.g.dart create mode 100644 lib/models/plex_media_info.dart create mode 100644 lib/models/plex_metadata.dart create mode 100644 lib/models/plex_metadata.g.dart create mode 100644 lib/models/plex_server_info.dart create mode 100644 lib/models/plex_server_info.g.dart create mode 100644 lib/models/plex_user_profile.dart create mode 100644 lib/screens/auth_screen.dart create mode 100644 lib/screens/discover_screen.dart create mode 100644 lib/screens/libraries_screen.dart create mode 100644 lib/screens/main_screen.dart create mode 100644 lib/screens/media_detail_screen.dart create mode 100644 lib/screens/search_screen.dart create mode 100644 lib/screens/season_detail_screen.dart create mode 100644 lib/screens/server_selection_screen.dart create mode 100644 lib/screens/video_player_screen.dart create mode 100644 lib/services/fullscreen_state_manager.dart create mode 100644 lib/services/fullscreen_window_delegate.dart create mode 100644 lib/services/macos_titlebar_service.dart create mode 100644 lib/services/plex_auth_service.dart create mode 100644 lib/services/storage_service.dart create mode 100644 lib/utils/app_logger.dart create mode 100644 lib/utils/language_codes.dart create mode 100644 lib/utils/plex_headers.dart create mode 100644 lib/widgets/desktop_app_bar.dart create mode 100644 lib/widgets/media_card.dart create mode 100644 lib/widgets/media_context_menu.dart create mode 100644 lib/widgets/plex_video_controls.dart create mode 100644 lib/widgets/server_list_tile.dart create mode 100644 linux/.gitignore create mode 100644 linux/CMakeLists.txt create mode 100644 linux/flutter/CMakeLists.txt create mode 100644 linux/flutter/generated_plugin_registrant.cc create mode 100644 linux/flutter/generated_plugin_registrant.h create mode 100644 linux/flutter/generated_plugins.cmake create mode 100644 linux/runner/CMakeLists.txt create mode 100644 linux/runner/main.cc create mode 100644 linux/runner/my_application.cc create mode 100644 linux/runner/my_application.h create mode 100644 macos/.gitignore create mode 100644 macos/Flutter/Flutter-Debug.xcconfig create mode 100644 macos/Flutter/Flutter-Release.xcconfig create mode 100644 macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 macos/Podfile create mode 100644 macos/Podfile.lock create mode 100644 macos/Runner.xcodeproj/project.pbxproj create mode 100644 macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 macos/Runner/AppDelegate.swift create mode 100644 macos/Runner/Base.lproj/MainMenu.xib create mode 100644 macos/Runner/Configs/AppInfo.xcconfig create mode 100644 macos/Runner/Configs/Debug.xcconfig create mode 100644 macos/Runner/Configs/Release.xcconfig create mode 100644 macos/Runner/Configs/Warnings.xcconfig create mode 100644 macos/Runner/DebugProfile.entitlements create mode 100644 macos/Runner/Info.plist create mode 100644 macos/Runner/MainFlutterWindow.swift create mode 100644 macos/Runner/Release.entitlements create mode 100644 macos/RunnerTests/RunnerTests.swift create mode 100644 macos/plezy.icon/Assets/plezy-cropped.svg create mode 100644 macos/plezy.icon/icon.json create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 windows/.gitignore create mode 100644 windows/CMakeLists.txt create mode 100644 windows/build-installer.ps1 create mode 100644 windows/flutter/CMakeLists.txt create mode 100644 windows/flutter/generated_plugin_registrant.cc create mode 100644 windows/flutter/generated_plugin_registrant.h create mode 100644 windows/flutter/generated_plugins.cmake create mode 100644 windows/runner/CMakeLists.txt create mode 100644 windows/runner/Runner.rc create mode 100644 windows/runner/flutter_window.cpp create mode 100644 windows/runner/flutter_window.h create mode 100644 windows/runner/main.cpp create mode 100644 windows/runner/resource.h create mode 100644 windows/runner/resources/app_icon.ico create mode 100644 windows/runner/runner.exe.manifest create mode 100644 windows/runner/utils.cpp create mode 100644 windows/runner/utils.h create mode 100644 windows/runner/win32_window.cpp create mode 100644 windows/runner/win32_window.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..a11ff7ef2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,208 @@ +name: Build Flutter App + +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + build-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install dependencies + run: flutter pub get + + - name: Build APK + run: flutter build apk --release + + - name: Rename APK + run: | + cp build/app/outputs/flutter-apk/app-release.apk plezy-android.apk + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: plezy-android.apk + + build-ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install dependencies + run: flutter pub get + + - name: Build iOS (no codesign) + run: flutter build ios --release --no-codesign + + - name: Create IPA + run: | + mkdir -p Payload + cp -r build/ios/iphoneos/Runner.app Payload/ + zip -r plezy-ios.ipa Payload + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: ios-ipa + path: plezy-ios.ipa + + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install dependencies + run: flutter pub get + + - name: Build macOS + run: flutter build macos --release + + - name: Install create-dmg + run: brew install create-dmg + + - name: Create DMG + run: | + cd build/macos/Build/Products/Release + + # Create a temporary directory for DMG contents + mkdir -p dmg_temp + cp -R plezy.app dmg_temp/ + + # Create DMG with create-dmg + create-dmg \ + --volname "Plezy" \ + --volicon "plezy.app/Contents/Resources/AppIcon.icns" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "plezy.app" 200 190 \ + --hide-extension "plezy.app" \ + --app-drop-link 600 185 \ + --hdiutil-quiet \ + "$GITHUB_WORKSPACE/plezy-macos.dmg" \ + "dmg_temp/" + + - name: Upload macOS DMG + uses: actions/upload-artifact@v4 + with: + name: macos-dmg + path: plezy-macos.dmg + + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install dependencies + run: flutter pub get + + - name: Build Windows + run: flutter build windows --release + + - name: Build Windows Installer + run: .\windows\build-installer.ps1 + + - name: Upload Portable Archive + uses: actions/upload-artifact@v4 + with: + name: windows-portable + path: plezy-windows-portable.zip + + - name: Upload Installer + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: plezy-windows-installer.exe + + build-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev libasound2-dev libmpv-dev mpv + + - name: Install dependencies + run: flutter pub get + + - name: Build Linux + run: flutter build linux --release + + - name: Create Archive + run: | + cd build/linux/x64/release/bundle + tar -czf $GITHUB_WORKSPACE/plezy-linux.tar.gz * + + - name: Upload Linux App + uses: actions/upload-artifact@v4 + with: + name: linux-app + path: plezy-linux.tar.gz + + create-release: + needs: [build-android, build-ios, build-macos, build-windows, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Display structure of downloaded files + run: ls -R artifacts + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + artifacts/android-apk/plezy-android.apk + artifacts/ios-ipa/plezy-ios.ipa + artifacts/macos-dmg/plezy-macos.dmg + artifacts/windows-portable/plezy-windows-portable.zip + artifacts/windows-installer/plezy-windows-installer.exe + artifacts/linux-app/plezy-linux.tar.gz + draft: true + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..79c113f9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 000000000..3f600b8d6 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "077b4a4ce10a07b82caa6897f0c626f9c0a3ac90" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: android + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: ios + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: linux + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: macos + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: web + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: windows + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 000000000..8c6789db0 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Plezy + +Plezy is a modern Plex media client that provides a seamless streaming experience across desktop and mobile platforms. Built with Flutter, it offers native performance and a clean, intuitive interface for browsing and playing your Plex media library. + +## Download + +### Mobile +Coming soon + +### Desktop +- [Windows (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-x64.zip) +- [macOS (Universal)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg) +- [Linux (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) + +> Download the latest release from the [Releases page](https://github.com/edde746/plezy/releases) + +## Features + +### 🔐 Authentication & Server Management +- OAuth PIN-based authentication +- Automatic server discovery with smart connection selection +- Persistent sessions with auto-login + +### 📚 Media Browsing +- Browse libraries with rich metadata +- Discover featured content +- Advanced search across all media +- Season and episode navigation + +### 🎬 Video Playback (MediaKit/mpv) +- Wide codec support including HEVC, AV1, VP9, and more +- Advanced subtitle rendering with full ASS/SSA support +- Audio and subtitle track selection with user profile preferences +- Playback progress sync and resume functionality +- Auto-play next episode + +### 🎨 User Experience +- Material Design 3 with dark theme +- Custom controls and context menus +- Desktop window management with custom macOS titlebar +- Fullscreen support with adaptive orientation + +## Platform Support + +- ✅ macOS +- ✅ Windows +- ✅ Linux +- ✅ Android +- ✅ iOS + +## Prerequisites + +- Flutter SDK 3.8.1 or higher +- A Plex account +- Access to a Plex Media Server (local or remote) + +## Installation + +1. Clone the repository: +```bash +git clone https://github.com/edde746/plezy.git +cd plezy +``` + +2. Install dependencies: +```bash +flutter pub get +``` + +3. Generate required code: +```bash +dart run build_runner build +``` + +4. Run the application: +```bash +flutter run +``` + +## Configuration + +On first launch, Plezy will guide you through: +1. Authenticating with your Plex account +2. Selecting your Plex Media Server +3. Testing connections to find the optimal one + +Your credentials and preferences are securely stored locally for automatic sign-in on subsequent launches. + +## Development + +### Code Generation + +The project uses code generation for JSON serialization. After modifying model classes, run: + +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +## Building for Production + +### Android +```bash +flutter build apk --release +# or +flutter build appbundle --release +``` + +### Desktop +```bash +flutter build macos --release +flutter build windows --release +flutter build linux --release +``` + +## Acknowledgments + +- Built with [Flutter](https://flutter.dev) +- Media playback powered by [MediaKit](https://github.com/media-kit/media-kit) +- Designed for [Plex Media Server](https://www.plex.tv) diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 000000000..f9b303465 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 000000000..be3943c96 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +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 new file mode 100644 index 000000000..5b48e7629 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,43 @@ +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.edde746.plezy" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + applicationId = "com.edde746.plezy" + // 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 new file mode 100644 index 000000000..399f6981d --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..60b14e5a8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt new file mode 100644 index 000000000..c545cdce4 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt @@ -0,0 +1,5 @@ +package com.edde746.plezy + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..2727eef3758f5fea7e4f04ad7d2bf8dd9b7d84a9 GIT binary patch literal 5248 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 b|M-Rf+j=yt&^^n*z`)??>gTe~DWM4fP}=&3 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..313637563aba08a445af7e9b6ba1a3ecdf8e6fc3 GIT binary patch literal 3220 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 000000000..f74085f3f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..7d04f18d49e3760624659b9c27d370d212d658f3 GIT binary patch literal 7314 zcmeAS@N?(olHy`uVBq!ia0y~yV7LLo9Bd2>44n$PZy6XER7+eVN>UO_QmvAUQWHy3 z8H@~!Omz(nbd3x{jEt;|EvyX9v<(cb3=IB6pPY)KAvZrIGp!Q0hF-QpO$G)A4Y&;@ znYpROC5gEOxb@r;4*15vAbr@=#WAE}&fB@1F(IM%kDs60S}T|mED+-2Dp>4Y9B-t% z`OVf#n~c7CniyYS&hEX|}L4QiN4nw8Q=Y8DxYEf zo5z?vrtbcE_os&*p1bq9=KatA|G&>Ec4c93;0<)&&&VK`q9DN_(wM*yz|zF%AjrYS z;5tGCUUn{Soq6Kyu|snOk~}4XWXhNS@pIp=Uhtmz;Fbrk6JCG%HDTU*+m~;@`Pirb zcIR|HBl#}@Xg)l-}jxnu~l8|b4IMQ^6i89;&+-I3`}oyxE@--S+`o8?^|59 zn#c`-Hi2myS~kpy<55gnc%ZUp`sXtn6a84`Q9aJm;OxGJ^5R|q`CO^yXnM# z{mwP#=fom+S9|Ap@4b@P1ndH8H5DjR=R9xKon!QSdB+$1Yi-AB=Eb-Ed6US{B7UJ2>t;WGztup)B<-hqxKMrmd}S6{Ru<)!9TCyr0yJ_q%u(Rc z->zg+c;#98tXr$wdefX5UmrUzfAjaFhIhr+TU=P@d7SNER9Um&aYKUcjcqNpKG|JY z|L~ulYV$?NJy_ADxbE5eEc>rNPCuS!F@N6v?{6;$HeO^+z4^9q!-BsJ31>IN+`pp4 zaaekbW6C1K9-T9f>&_M3{`5`vc1+!})q8F1g?rvUv{KJG`1t&r9*@5UAAHW3PrX!? zd3-K+f|r7XI%9T-#ErIZUO(qVXl$r0&UmjM_u=cD{m*=!bj@F6>_79Dh2wALgBB4_ z^>4YFT-|W-0rkm?rhG_5^)jkv$jWV zlZ=zlIDLM~=`X%=8?z2MT>Whc{}x!$SVV>`8+d4>STv4s)Kp8qon-ItnDy>aG=+_yLW-9IaTrTC)z^T+poYZ`skSa$S| zyu*fTaz1g(!g*fL+9uhWsjZv1VB^nea*w5_R$b8BCid^XzjTpGLdJRF&;0Fs1!wke z`nQ*lCELyH$n@*Y27lx?&tbQ@IOVgk?$2p`MtdJ#PKvSi-McN}(^}g=$>^qYM{8zA zYHGfpaG9-y=^)2+DM`x};m-}UWK*l0KU+?0*f`sK#qnQ}b@LeK$92W;ej4@2e`+aj zubQ3l0S^U<{U;ndR#|8zDrC0*@wvZYV`WrN_Jtpe)?$3$;�XNjV_@F*3Z@O7g%G z2Lnm3pVN|V#s*8=`1bA8B2B)_Mtj&xw{QM(|NEb0@#m_Qe|K1^{A%8@=+mBWwv7(5 z0zJ<6nm^hdY58!-=lWS#tV(!G~Aw#ftmAef%%0AmrFIQ^kw&&%>@I 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000..304732f88 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..5f349f7f4 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..cb0ba33f14fc3371af7bd8ab8ef218b9c8fde83a GIT binary patch literal 4750 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..ac752da53a4fd50e758f989ed84c1aacf14b8fe2 GIT binary patch literal 2837 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b6335695d7d85c99e2af3e7e7a4b3d0369b949a7 GIT binary patch literal 10341 zcmeAS@N?(olHy`uVBq!ia0y~yV3+{H9Bd2>4A0#j?Op8gLs* zGILXlOA>PnF!h)kLM#!VUL?c7pgzsh#WAE}&f8e_nBes9b^6u$6MA_hn-(l6eeitM z>e4G~Wy4m>hP|GraZ4-8eeTw6QQ5ComA+rQU{S{jzG~+iw^=+ISl<1Y_f$|U-}C($ zTVQ3~{J#dT&Q_nd{cdyK^0{nQgJMOrL}Xv-Mj87f2WvXY&7Je5ZF8#|6xbM8&vG<{ zEDtF#R_b1*)U?_AUA(_O*FzHy7N6R3@q02?MD`x||K@BJEAM;J7y0RSu0^&i3=LiX zWgHu{xB|G#jcd92jhX-J@7=v#fTQK<@<})9-*R~0ogNt)yzWD6$-~~pZOoR^uV=oI zzFYlVLj3f4frgGbPrg3qaJV&xot44$i2eSuRnzPP!^4f|U%#!Hu&w;Ne4lITyBl9j zD)U*_FPzu-`=t|m^EBqO1=~N`TdaF;sjSN@t@A|IE!oVw%`;kNPl+#iB z?Zr#X*qI-lkCyTLvU6tNw8xDb_lZCMVR0|2z&0r`L{q(grSzMoBFB@B``*7cs{Fp< z-v6$DvEB9yl4ecX!F6DP{*;z$D%Tww)-V0IWBUC*$&ckR-`^de(R;1bn)3`_Plch)a+$hLa5YTIr1?{>EL z&sRQN^}F!&x^xj&qsqsz32Xs;Up{xlH7@%VH7}#PddnYi<1Pjt_d}Q6*S@>*Yv-TR z7@sffv2#s-xJJ}HeR{xs%~}6Xr-Y{_wCi19S;=fE{c_gXk7Y5eOBS&%c;pfCRq#N8 zO2djDw`clRo1`C^qOHl=E_T23UO-56{NW9{yBwI8@R=MwUB~9ou%VjpcQTvT;-nvY;tJjt7f0@R<@o24x8~1>J&{Yer#QTDXvd zpIkCm`QQbq31t!?Yigg!^mzSU(R;1UBC1aA=h+vY$CEaf=T*(wvAgN|f%IcGu?ox! znJpz>e>t#BU&Kk~u!~^B%KnUPt|j7MN^gEz+5UD|)wLc@i|fIB+g^ZFKi z5sdunar3XKFYoTVi>v?dK5)MB;-T9Y@7tfzOx4WT|6ML%ufdxzX1leG*V=u2D?wcl!iZqm-wiuK8# ze_QDC?2zC7B`3>qh7w!teTJot4zCZh&$!4DaKXJ|%dv{+mLSK^rO`*;pW*qxoV)1s zIlZ0Io2P{yJGngl(synFk!yBSkCm&$L@__fw4czeRItTu-Z$osUxC_d6`f^t?0TaO zzD+(HICD3Xyy}}ZkIc)~iYYRbNa_Ed@bo3e^lLAc{T0~4=5eXcDp!!jwM6Vo>Ceor z-EWt#mOr9vHFbix&&^HmITyHW(KzzhRZzjzJz@*n)YOZ)-6wiIH*VvYP^Kcnz#Xf* zU3fCPO@!%zuXPV5FPWdWM0{_+iCga#JWK?%zW(<~i8Oibo?mv03sKIYthM@%Tqa4 zuino#``Br_ltLS!Xvuha#z^-`i`Sdq7n^v^ebPmz7s_vM?(>sgR(10{Z`G_ldHxlz z_8L{mKomTLs+&9oF@K$l&#m@eh-~xYShR;JJe6BXyJx%n z;!u#@UtimvtSGj(1sGxxMMygzn&&&*TV>mFT{I#6=LENb%4KJ)ux zA8n0uA51>F^W5|O`^7i6g`P@uw(Z?6a6yXW#GaWOBE=TW|JH2ge#rax()G+UF1YuI zm@K_i$KE}&@P6;i3MIYo4i6T$$hMmPDfg2Ir`;9D%Gm+ec*bj ztf0GPszvUS(k+kp6Mtv#__ZQ6+J5tah4c2GfBkrifOW$C;FWTAZSH&F$-nGl9z3r}eR+4r)xe`yZ*Dlhz_=-bd7xXN8mm3KtrQ$rXZz}L@W-9MndFZBk2#XPLbX0- zF2Bg7AJgt{FISO>n!J-Q!#{iOQeCBb*`J1ncf8(`aYgD?t{IN0QfG=^@+d^l z)S9@@B5KLod`+iphgp3R9={t!WKVWKK6hK}<2I9jKR)i6sC-`Si7XdKV(z)eAFOI` z@mnfyjEzuRY}&BY^+P6a!i&Y*SLz=6ku@=n@3i`^n>G8syZ$;Rdw$aYN1WFA(JXmE zwsTXDaxMEC!eYJ8E@(l8$E4ImcfrUg=90J9owCImovY-2ZY%%3^!A~k?SDik*EZh2 z@>8bMNvi++dzUta>+S~m%ZiruGHCU+Ebo7E?8u$0sEhw~zLZwU{W$UcO;=-H_Ov^_ zu?5O|Hw3>8^Pbnbu%bkz(zx>lhnxIKjum)540-CH~u)9juf$mIm!T6vz@Tzp(Y{)`{UpUH|vaimEVN+7#_# zvgFIDsEdXihLi5UUa?Y%`K?c_`i?07xsUR`@9f*P>aX=3vB254F6Vu8F~5EOTbt^C z36W_T=V+g6|J-+`7MA2UsOLB}7oS?>x}T{@ zm?^#LR)4(Z1=|Z={+sVx-xPCn-Mvw9S$2EHKeOm7Vw`>PbzM`xPgwU?gmr6ctI8if z*KCc9y12{^?R`Kw3!%TmPfIIRYw*;ib3S|Js$~A{f#xw3bolt^2zE2i&tvZYdchXZ1ewh}VAXnHb;y z?}dnV`u|t$A01OmWw%GQ9Q)@m$$s$`PLV3rtp(SQJ^10UZiDBFBflpyZg;$~!%ijf z_}L|Yx_=-4Zu>4}&GY*ca|JW47I|kgJa#>j*`0CjTVag#xvN!G2_BCoC10Gm-RhA~ z;3Y4UjQjux-CR)<^U%DyDHhL5g8oV0HvL-pa`l;o>TeQ`;hbbI)$c z{#u*zAMNFryZqSgJa0(_FPf97{3O$6X^F7M7r`p^)hXvE?Y@}ZdRF7@tCfvyv$D#S zW37W_&*m{O@G>~W+D$)xZq2H-3#!kqmOQ#JaH-ZT@}4Sq~Pb&2menQi{wwlwF4+`Mn*^B4Y;+S=MG z@U?$;-CJYid13?+9Je7h1ey+rFwRCnpI0_x=8VegD#rE*E|kOt~#q85vMocj{+)e329j z@1@6@k4>-C*64ltJpHq~)lKP_-$L8$s_w>1-+TQcM8AEf_d7Rzj&qv9Ki-(0Xl=dl zwf|Z3L`mxvJPW3`CVoGA*rxn<)Jz{o2Bm#UHPJ4t)_vdD}g;JlTHt{OE5&X)=4#w*1^3 zeev}oF0I4n{U7X>ty`6(W55Quyk1y!=}ymWqC?<>g1o7pE`V| zV9S&IM_2j_vP_G%T>G`NbKA1d_LG0x-~6vs`}1GNm%|&RyX1S%@BOB-U+K{WiTlob zbA8mj@A2D2P1YAm+U|Pk)XsyS^Y+Y{9&)ZVDrUuEt}P#q6u#c>@Iu)pYVyW-?zJ=X z#aZA`F}c<^l_c#k%M=>ADCem&TDe@ zZQNyLzrP)41S+;%`?OtfZ7TDfe=o9g@BE)F9eBS`x;4PfbNwn^$*=J*uAHyF*&k_n zpmzD(g?;@#CQJ;){_Z+atJh?oxFJ7p*F%q-Z>(CoAJxA}GnP8KNV)A&oz3ss7fGi7 z557q>TkK~$?@XqEgSzSzaWSSzTT0(5Y~O2mG<0#6RBMswbIW^ukFWEV*{{}Hq;s=m z^2$y7dK%eoN1xeioW0@k8EwJITvGXZn3_=aN6a<9}NC{oa?~FIv`i>u>58 zLA$jJZ(iM+`2WZ=nP1j=&zn|!WBEN->5I|T?ep#*&y;nrUZC3~c=mVH%${0<>KBs# z_ct#;B&W)?|DuIsy~nS!C1w}p%ko|=cyRaOO6fzNwC9~Gv|gE$LexlwA{qd(3Vw~?CBPotF?NE)~m$4pNqe&l8SP@%(!jYyy?sN zb}8Ok(5SKL;YF#`-w#hP*=tWesjkj@=ikex&tE4T<2774uj=pNn=bh$soH1Y$)PVi|6=2mdJ%UjMH#l|y2o#zIrmP|@bM%2lyn&CG9Xc^7!-+qn(8bASC$ zZ_H+^uGxL;{^M|l2DJmT&79KgyMA#@cVbi1xjV|Pyg zci@d@;`&_^_OSMZ1>WXa$g#FFVad{mKc4@n>z98z?ce5)fsc0G2`}9uy=B7(Bl9gh zQqQ&)XOwr}%snfmdXxXT)unIB)eD#Jxb64zTbf8M`=l1fmpa_DwN7OA-t;}vd2J43 z;a}z~)3v7x+Veik*Js-OP4fEpu2%n2edsZ@fUv5lw(P&QVdp+=iC?#F)uQ7|bPjBl zXTI#lv(=>PN2KI!H}P=s2Y01gbL%ooR!8$*xhEiyE7+l>Wt@G^C0A6mIdUFf^s>qu zYj;cyt9T=#{ryAtqgOsgwV_v ziVtjyKYjm;nemIY6PP_Om`Qk@y~?*@5mQhU^QAtQ3Ex()Tp>|=>2lsvb-6O_qWG)( zcFc_{c^BvRa_x^x;WJa-&YV`?-*E44PgTu}49m2p*d{Dw_-L60^i>C~|s^j#6UIpN*0DrWI-<+3|g z?0@~Q$}u=nZ@Hf3|Ik>!%G%^i&s(u-duT|Q;;ap%@QZ#M1v zrIaXqO84ni?%Xe>CRaiPjNP4MtHpzD`T32R<7<6O)0ceJo_VK?HS=$bckItt@cNX^SIK5;K+O@`Ns_LT=UL`x4tO=so)M_?-Gi{urN=_R+EY_Zg?T zIaw|@SU9KnjYr8fL-AzW)%vx&>W!}+F55V5)+))T+c+98EZfZIcgyd??p}>m{S03= zDbLvC85gSi&_tw*n|o$tO?cqp?*FD*+8?T_9{NfYa&@11b?cFDzwmaMt}`|p*6e>h zYv#Of-hmCTv@VHFG}FivX}i}F`^dvbhE+@6b;rZ zW&d{W)4pdjo?g81=IimK&Hq-3n`!5+pM7apm7fDg_F>MjbMlUd_tt;hYBKBRttniW zqz)`lmWWC|Rp;4=y{TvLk^=6;e zZvN}%Ix#9R*x;|zjpg&d7Fq1AT5wyC*|$`B!IDhnPa%%^=4O?@78PhQwEhy@myq9m zsC={6x{1DR%Ja{CtV)!aXfaKK$z6V-LxtkZzmL~F&$O1BJw?2C0T!{B)Vnh+x(7ea~OSUTi#A!S)IA^YX1de&Nur{=5TMc7x=5ws4p@tuWpXX z>%6d&$(tHAq;vSzf4Y47;p94*JH=2P$S23J|h_03NlBxY-sMDfe*Z{Mih$KJAG-^!yi#C_Rpc};r*{iZ3i z-gM<~Y*%*>E_WghMYuU0zSGe+WY63$u40b=N3JDZ_x>zkgUBrIfnziy} z6;Aux*OyK_!f53oUnnK@`5NoyeLH4_{qQkb#;?;G!`J)1{M@;K{(A6?BXWT;bALU(+TrMa z?Caj-h0Cu7PwQh?X~z`Q=i(~=w77EP=XEYU-m50X+JE2lB=p&n&*$xUX4=}EbMU_F z_{~bT(8Kq1?Pkk8;>GJ-b=tQoUs+Pc)xKuIez%l0OCQ;|MM*_<|FT>^>rviruUOZk z0edV$|J+^jmH)^okMaXi_1_uq|FHU3@+0-xt^Yxv)*reyP3V-xo0sDA`K>-pd>{Ee z&n=m;%`tIV>mi-lf{LqlygF5GzG$D$!>h^jx&se9-TzBjtN6E6egEMvn{*aj*dlGV zT<_mvWB=pDw{>@`F)d-ss-FFXSLL(V6j!m(rHAT%rlg;K=>5mGZ&pUO#rD{4x&6}F z*}K-szp6}K)qeJ^D*vPBn=Nftlzv+D)Bdu)o&5IH8P|`Uw0+AI$P&nG6msxLp5%%L z_jk-WaNxp{_!DvK0?K|qHvK2Z64>G(f6=$XCHv7SOT}P)+xgSZKMg;8!~6q}jgz`Kc_vT9ko7v}UgBlsvE2)RpIBb$jggncTJiwfuOeaek_p{ox-~ zmLFzce0EilbGH1msdw)it+{@G*{xacJ_WKQT#}k;_sE3n2IIjsH_b0~J0%`d%5j?= zFsbrh#4nDvYa4{+mV7kPF26mi{p0Nw>ynu_{B}8AZ5~?E|sxHj)~_Nwyy?wm7M z?HhFVudaXmy5_*qi1W!;|NfBw;aiX&?z?z{p@vE2^Nly=*iWy!;9N50`}bF?w2o~` zKD1um)O4Zf^~+rSvb|^T&38)NEh*@;)pYCrx7U+pbGCV3uM7QiY)-_o!`y) zyXax#wM<)=%xrt<%lg~-zOT9?J8#>+sUhvPQ-ZfAIv%c$4GIr5Y%KMVbiQDdlDZ># zJFB|-_w94a|2|DO?R8p_8}FjA>S6eL-n85sg42Xd?w$RWTp#_pa9@~Pv(Hx3SeLTN zUcXzX{%PBCQh}!B z{{wBuI~||$$QH2g+^|7*|Kv4nvTswi)~skg`eqB)yUSj6$-T>WPMp1TUfO=Kz0Ah^ z?HR@1oyv+5^OH+v1~S;3_YiubxzdZ7b_m^sC-_lZB zUQ=Uj+uXH}qUxCa71pd+w|v`T*XkWJUPQ{u-haQSyYrsHs#M__O==E-2mijAB)t33 zrEQx|#XZ$Dy7sLhE`npp57`M;LQE#1p=bB#_NpgKG4dj`)$|=*$y$@sxa9Hp*Jvt=yBEhZ0KW5FJ?Y*CNCM;N)#~SrjwP8ii;g^e_ z7`(^5F4DMRm8Nr+tcc&w0hSb;E?9zs{ZQ7Z|xNXMKDvuWRxq zYnA<@Q(q2tswZt`uJ1o<6{Fv>I#H7;*EDpiD07VX`LBg{)~s8#$lpC)=z^3~Xh?14 z+rv9jHAPK#yxuW`?T_2zme{mTJEbnAPl=Kj9+kh^(q^kBvSOu@+vnfA{+Jy-Tz2r@ ziJq_5FLUJ^SRM^y3T1KJ_hHNIO%)4nhY9EUmjBdnI$t;Gym!{C$lLSPbazh((vtx;~%xG~8zO!Vd*sOs`yF zyE%(j;`ZUHPbc0z+q~uK)iu$*?TPk#GW=5P83GQz-Ezn|`cROWc+tb{?}FJLh>JCS zQ|dc;*Qs+6M`;vu5TBZCZF)m5A`Z%Ad_E;GMUT55jz{o|`=3mBLLn!kPTHLq=YrF7y6*Sop$ z@~Z1PG*-3bMQIC9;O0KM-LWt-=l7QQdk&B8U!L3QZl)WOx8};Ff7ib+S(Nl5HP7PO z=|dYzceS&0{ID{bfBKGe>s=#{H{n6i^LPAr{k3be=Nt2~SAt>-zIm`LdCPM_ZHs1F zq(fhy#1(g0v;8wFwhK!B7POkQ=kE`u#Fz3HBc=a!o%{Fvt}T~nclLxz2%`>IFgYX60%?Riv_rTB8D)LhmJlV5#Y#4%x! za!l0Z3loJhe9L@f?};6h%)VWE;)~!Vr{8~s*X+^V-?IGOy7vc+Q)e<9Ipn0KKAqX8 zndwxnpimSu*Scj>3q3w<T5T=k z%yze!YwSTKR|2cwd0dvdHsi&GD8cFC+n>AM+7vB$)IVXKV>)YZYwMH5nKAMgmzyel zEwJ*9W;w6exaZqyagNh{$Diif2u)siMb^uTYl{EtiJYfZGPN5PD(_nDn0l;bZU2)@ z-gtRN3HP7{3vOPy67n^^=-<9%LrZqA%CDv(^MjYOw&vDZulmQhvj2sf8AJ4iB^#xs zzipEIBDwp~jb|5*m@cZkmh!FcHp6zKP{oBeF8=Zmx;f+R`b76_X0gqt*A7M1UC}=u zJl*buUM=5Gn|oFcJ{pt7x?BQT;^j}K>w%_soHMkS^lNN!t!P>{DS6|~*Z#*+<)4=~ z1vgIN{BgNT!&NXcikU&XiynF-M(ILS zM8``HuLY(Ki~6ksqgi})9>@HDn)&I}4z4`0JwNZ)1!SjQSW$oA{jz^$+gw*w@V@*V zw?u!}%)l=bCOy3`I6+6pCED+GV8!{w8NULptrpqujQM6Ia;fg7;Y1y6H^%o-RS)0Q z>IanhZsLnI^XF|kt(}WTKsa*fZ>0yzIsgA97i~EU9|awZ5d|z0EI=U3D(r@(UMOE?V*F%)AdPkw{XCZ;)^eL%=xM={K=2&V)^dB2UHfA3QQ25!!;*T_3?x!kz$d5 zJG9n%_H#O3{AC@f`kT)^^?yv?=D&I39~_J86|@3doNoCC%O70wcfr(a4M!9&ZrKzb zUhcYNz1Qj$CzoX(_%0hzt&)@$2?^FIx0OU#t8*7Cy!R-%_Gooxt9eHBI_~w1JJ0I3 z{u8*lCBR`_e~OIImVPCZFB5-!W?Q)7M^4efzGBZe_SsUcTl#LiY0^3IocW{+_oJW2 zbHn5pa{NzAyZBD|Vx+YNFVnn7X|J1DQUpDMI`YJI1I~nU%?*%W=qY-KyZX@OYbl}E z_f0*WW@UXsW`&@@(${Y@*D`;ZSaam?HTR`kzixC--ZZWC$uvhG;CIb?ct%n_s7-eQx{~)iUE6Lz`Vt zQ1lYZWh-8NnYiLigAU*N4nywJMu8T-BkNXqJ2IYPXSn$8hi&;$U5`$_7pXHpX4ZWT?JF}coehJ@{^G`)v zpH7vRdXunE=s{ZK+ZMAl5yc2I<`Z879dqw)R-Sm%dvVw5%WpcAEwbsn%BJga{@OOTuc#JV$u5vtu`BL?rO<5S=k5GecbGX=aWM$W zUcBypX(BJ{**y<>F2&ZXKY9DejN{(zRO7j_0emWlgv%;7G*3L&#ktPx_r&Y_E_#_+ z1hP!J_T;sDMv&(7I}>NDF|}O(>eI~KSM@ZQFYu`x@(jDTewCJ1l>F|9w;8X454}}m z3vONa#8geD|76^cB@38dZF#D6@Xa^L1uC%KW~3R#%r^;51f`FB4T> zam<>%v)s6Q`+U!&_iM9rf__YxtXCqVVLgFE{1VrBm9WCa``$jTIdaV*U1Gw?O&S-Z z7|wDY4ORc&bDs0W^H00O?Dw=Tc#GFpYgRP>Y?*2Mw$A9{#V<#r-!@!~ z5mlR3xPFfJ$=lNcPW7fg@!R#m&Ea;Vi@?&WiaS%y<(^Ny`(dNOi_4b}RfO;h@BL@S z7WMAftXYea`@RS^bXYx_^GK>LPj0&S^q_;aQjsC0%a?51H)rSV7l&pa`p?Kbx6I2o T(8h&3_*8t*cliYR7+eVN>UO_QmvAUQWHy3 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 X7VfBKQt}kC3txRwB;%Rm8-_a1Z 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 FO#lRj(=Y%4 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000..d8acb917b --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..ab9832824 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #ffffff + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..60fb10ee5 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 000000000..399f6981d --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 000000000..89176ef44 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +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) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 000000000..f018a6181 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..ac3b47926 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 000000000..ab39a10a2 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +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.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/plezy.png b/assets/plezy.png new file mode 100644 index 0000000000000000000000000000000000000000..1d8925a498f54c7cd373742e5f341f639cd99cff GIT binary patch literal 89095 zcmeAS@N?(olHy`uVBq!ia0y~yU||4Z4mJh`hI(1;W(EcZg=CK)Uj~LMH3o);76yi2 z3=9knFBlj~4Hy_+B``2p&0t^NBBH^P!gnQxgl5(w4OvMlDHd0_-8XWE~k(1ybA;1SSa>@rRaP`>m`Q zG&SU|)$cib-{1Q^=l=KFTgyXVulXIVzWbf!_ww&i?hYQp7k(X&YKZ;j!hY|@)Cawb zHkrHJV|cNSiHEslhEp5!4ZZ{07}^*m^cWZ_c^kfYY?nJArE$$+Lqwx1!!(se-U~KK zm{zjJzVu$fTj%sDeD9;(WlkK5Edov)oQxlJ8>Vot{r{VzB&Wh%%R-{_&t@j}&Lnw; z-OMYVp4GPfXPjJMzMk>Z4yXU~80y(SScvo|Uo5MitiA5VhuVD*Qxum(USLqzc`RNo>0zkgS}w;1|w-k=Jqwr1@pPydAj^-=v!xhBJ!=i*~=?}5Jz}i zUdZTRwD__c?|UheCx^9utZ=aBI-$rA^{e}P9+O0uzzgn%Ka2<7Pe^FI^4#{_3Kobq zj!Y{P1`lVmIY!@NC%w4HRKs>)_l3Q^_g^$`mA)g%xc;*W%jbnB=SF~c(b5F?H}{@D07Agh7j+H&ab$W)wU zGZ|e8yEmh!LG3*I3s0`2b631{4ch-tk9{dDX3{bkrcB{ozHM(fOR%T~o5Q8w^0&^} zSNo_Pc(%tkpZmo#IRHp!Z8*UBd!UqCeo&_9@U9mKH z>AipAaK~^kNHJVoblHvf{spTSDvdr&8@?9UpLcV}EN9+vaW>Nqso1TOMb}KvTWU=d z06RaZFoVJ6#IE3e*_j=2Oh=`q*~ES=@KQd|d~a(*TuXUaR2yA)bLROXT_- z{%bkh9~PLieo!viHcu=4{}WZXXSEo38yqhFIv!<~bK|Q^$2q18v!2d)R=Z)D-~-3^ zSq|sijZ(HNW-?Sv@9HvL)JYRU>~!U@H3>pwHr^{UKN`I;?X5~2i&t0gHH z7&utg%J2Q#c5d3~g+*)5?#bcauyWo0J@NgwQze??F4e8hBe!Ho>@RKmZ z?XQ0Q?wnioE{!SRgC` z9azYCLB(v&t#9{2zVb0x=l{!;U%r2d8v`h$zFNsA*ku{Ty}EAu8Xn0DE-+*;z4nv- z?CJY7jd9!EU)%rw5oY<=yY+8^6r=6uDr2T=OjU+Lw!yF7u9ksC1}BdcLtkXI*X`$w zdxaddFT7|8Pd7hsZDHbe)*GQW!u|3;Gsd+%5;-%gd@94M1+d)UaDk!V#IDJ={{_41 zyUIVG>E;l>P_CyT;oREuKN&tnt5^lT`uwgOmO@k*G8in9-fHH?Gkw|5sM7n$E1PA5 zuEO%Te76RsADoj)W?i`*Uj|csMA?KvX2Y{5+s=2rC}qfs?4P*Cp7$9jfT6w!V=l6r zrfcv1OfTX+%<~=W84M9=XIJJfcKuq#pu6n&v@&xC#l3U*A9%i=%-}cC_}Nx?@_xi9 z#UK`0erkrl_LDG%=I$p81uukalrvXcT>VSi0aOsR!-FSN$b>;>zEGIFgWKt zJ#$Cw!h_?i&Uc@rL_7HH1tsy0I`P-huZ(`rO6~Jff#iORHns-VYxB*%9ay*J^>?P6 z_e%E9nK$SzD39Ci*3ft@oN-QL%EhHY`*&Z4MYn+a1qO{1U6bA4Us2j>&vZln!^eZO z+#FU^B^xpFILk8^G|e&0Z-l2Oz6Fc{3~RUZM(uX2=X-!Ysxd290Rv{f&*Z#rgP{z0ys`N-ny zax=?WEH19ncbsgMmH&GmaGlw`c;%it_{ z)A?y!#&wmMSO2lYb61fQ^MMIlFW2mVU%EC zW!d^<)jaus=6`K=t$i@-<8r=jP0ueyFWAtt@M#8iys;NQ=5^$<}Lg2FE<=^bNxOwQ*pQST7L$M z&~r1czgu7ft@8LY7)%nrZh8F8W$kA{hTG|#{V$v2AMY_?dUPR+|NUo1k@AIYCxhmH z-vCRMD{XQZGhEib4odm+f&a)Z(LMX`x2(MG`r%qG>+Xq{eaTki5fqfj`^t=bOS+Rd0{|pZeb9=oejsa^^3hQD?U`oUPoum30r- z10I`MaY}FB#=_!nNzny{7tNyT{r6V3-BNCNckQ=%UvEQ#-H+5iz89*xS&yf%RG2gJ zxJtCxalJ^feEe%ZEMzBm9TaP@Taj7x@0$F-#yzvwKfcX1@1wcnYhAHB)>r0LiRbbx z&E;m`V|lXN>hX=TwesK7*r5%dAk7`j3EW}xO`gSvd`OqPAfs6y?eM?ysLGDas@dgv zmKqnD@9_%!mAkgi+V|Y<+@-MK(KNloxPevchgn0)gM7`;weO`K7wkB*ep>a1dA4iM zT5kVV#(ZR(SWg*?#FMiJ6jt~gn2;-XS@L-A<*K>wV#5oo>s1}-u^FuCETTrw=>K)W-VX(zmrY=U!zX+>eZLU zF4Si`81B`}IQPKTI^Fu*)(5!(3m7t-XW6VW?q8k#*V|cU3Z%OC$b7)Zz;ODNd;a}h zdX-EQ=Dw}-mOR(T@Q&@oMDu0a8l0+fv(HYr+k5T#LJQxtv%fbse}c4lJLTRnFkINA zXURBWE@Q;MX_r1GGxIk6ym3IL()-s9hsS%^EKRe^%(D~oIqH@e_x)XZ&Hg(dERKXs zr`~5=5WLSKr}Xr!=L~PQT-Ep9C9SZ3)}G3rHxBIha_ivdng)>rS3TH#C;kduZvkx; zPFS(x4`0Jo-RymR`|SQO^OZ4&l-e=KOG`|Tv)(a>e?lY60r81>KX#w9$=h}9Hk&21 zXq>QO#TPyX+4>s~t{!f)<$W=4CZER3K8ADbCniq+%vhB5)lB|Dp@ZK9<8`|aJ61xX z(#3IUIl~$$voH1{yOyuc5AAjnvD50vZ!m`D&0;$TfzOQhm|oP(HT))S^|+WFZrg*E zZ}=E;vsbUZrC%#@fN`gvn`b%mlkmyr`p=nRG;W{Z%_)Vquuvf^`{`}zMj zoR@q3>J0mdKUoKBl5-)g9n%^72@}kj*9OdNIJk9r$+H`sx2}a12SA&72LmIS87BF? zcptvhoas$_)P+;q8ZLa@bs#^w;T=l?AD_>^C0~>6AHgc*riGJq8MK%8+6j1m+P>!F zOn;%WA?0DZhZV-#OvJM*09vEEO4b*!*xHY&H20)m&)$uaBD15%a1eU{I+Im z#Rd^e3y!^IA8a%VOckChOBzML3SUAj352HCFU&`6WkjD?=ecUy?E(y-EN8Tak;0k5`0dOCbI+UafZ|%AFhYH*zMic zd{bZ&Fx1+Gq_z2h3n~VXIZyEmmt9LKI zfqQ=uL)GVt<@b_u8QT`JZWZHCi*`7_Cy$LaO@Z-)>lU^8>obr0Uaz{X0L!&Wt*0ay z0%Y%gxZd&i@cg-5NmU^+|0{2b3G~NRr{r-P@SK*|#k^qiL^b}bzc&ucPH_j96ds9M z7nwE`oq7?wb(&7gf@xo8%vg40vu?tIJxM|`4fm1`PWtZfKI-kmow^JGcUX_ISQg(~ z40rMjuQv=Ar23e)uw8Xfza`GimuBw3aG&A(eq{xpy?RaC^SCwE*`3?^V41E$@QMeM zJwAln{=Ly%QgyqG58Bu}GQ&%Pp`qu_pZlU0a>{=f=Cf`O5A2cuXS84HIWxwL%D z8XWKOY;j6uVpH#bd1hn$wW_^J@3(_fp-PC?eWnFpt2PzAZT4o&xu5>?U{3fp`8SJ9 z70#`F0BtSl-C~M)shbV`3>YvCH23)o>vM>Q?)_AbXU$@Sy%F4#hYBl*^A~L*w$e2oO#Cr z%gss_@&~%(4o>v=Dmb_DP0rfwxvyba?AMYnYzyc+@b;Su|#`(B1Pg!$M%OJ&$SOI#*0x*>6c z$dBBJsLzMDHSBY;J;ToN?9Q(_{0iTdE_MdZ|LzWJ3I^pdCp`UP$iDPKd$;Jj`y3sa z9t-04{?u427~rA(oVnrxs9*1C<@@TbzBRM}51A3PouPzL{^k4fo7+Wy&*wj|twAZP z>q&2oox{Uz4CmN0Hr)f2;cEV`&OCl)^>4#LSTu?*yTaDsaNjzB=Q;C{OqLxDpBXLW z7nEPU_l%i``Af+ggGY{Q`)w~li|-|irrcs+_~0Me)KkvP^5Nl;^X#u{u9~DT+AAyd zp6$Y#gN!d=4TF+68BIj605(D(A~J;kSgGRS=?bNaySQM3v?SOw{Y1r>Q^Gw9f_%&pJ3E?n7f z{<|P4Mbe_t%|Xz#^0S+r%7zJX{Qle;ORmf5^)N86UC(5}d~%WZ(z^2H&_;oWr)Dm9 z!;;KpZ~y;I+_rVsom@u8Gwd7Wv(}ggi{@~1c&+C)+RiG`mdbNi@PN#c-L^}ay7F3SMn5ml0S*C~PL}>xFOSD9`C2dk z5Sspil)SPTG>)ud++exhaY@=X9;JV5H{x6k>wcX6lXcK%%XaBA$JxGtyWgPtYs=@r zxs`9~Gdk0EgBzF=lBRrf`?&Sx@im8*sWbeL-}dZ(CgZV6TYj#dVuP4t_gU6%kJ#|t zvGL%X=zM=}28Hj6FK4NI{dV69nyg&BGcpV^cppD>TQ)&A~^lA%5@A1%d~T zvwb`F`YcBPgZYBhW})YHJ+p+S-w7u)Qn?*Yo2{+>_Oq*ZZE$PY zcn_N83igWaIm<4=_2Q+3UjOT!jmL}a7(gQ$9$Ph+GA>}2U~u4&U#3)>vmjJiVSb$W z!I~TedFdJ2u%y_%@Dm%Temnj*>UHk>5@`L_*$^bfuxL}bjr_7hhDB5e3@&0 z<~W|MInqiZ?L{FUcjv4$33redU(v#Z-`Cp#dn9BzpRcs9OVrI}RyYVW$@y?s$ z*Tfz?Z!Wd?%=l*uYsgiO2Q>`M39}cnGwg^<@A=z$?#CTRSPE$3y{$FQ&-0#Y^ZL^@2YKQu%OoohRS%&E2 zyEsfwo&3qbD9QNESvMm!+Tlf2?1P=U3GNKV1qq)foU{3M8CEsu`N$|amrt}@)nVsn^tyWZF> zZ4Uo~`&$gSmkDmL%zqZny{&g)gu!#>FK1ro+M#w#_O7|k59%#WxD<3vdPiM4!;$TO z&9e(yav2#V81 zpT22dc@jE)azsegjA7Gs@#nnt(;SpfA6OrDI6;cB_fwbWhV->Zn=@IyoOykgBZBwD zPmi`U(|%2T4IAO%3c4oN;G1&$iq!LO_jMX`xI5hQ?PAWbchu{Kl-oHxd-Lu7@ixXP zt4mwKnG80F6tUomHN%0YY05o~X+N)bcHIpz$WYRy-`N^!r`J^0!>Zkr zjz@VK-lQI{JUriU(q~4ci?jK~a~aot*`#}5*{*+|s*IU}7{Zk$O6mGA(OcUxCZ$G-{V9x)u z5-V>mdVG&7Z~da8HLXegye2%Rr%oPc+x0K{^S2uZ!dCbkcyKG&s`!mdZuV+efs^C7 zlD8r6O#T8(d4(Q+^>5A1hL9e~PlkoBOB=ua@Sh!J9Mx&fIHQ?)vVG>?8~w0OM$V!u zx(wgA(xM$qs&0xU1m>4M0}acig9f&rIbY^1O8adLO`0oBrW!MR5|7&lD#V^9Gb>-3 z&3}%4f#3ye&{&f00`cU#yEZ@Bb2|)LM=Y5XvWRtoe#|yjP#*ABxXtwFB1p%BnudF! z)wB6G@G4w3OquOodhEvItxMcYL1lFZzgK1Q*H>43tr_O{ZAp)Iu&FxRwDds!;Y+jR zx3PW^&GO$nf7&#qDBEqU4E0URgj2(Q%R>`xkmRXc28l2ay;aF8C|c4oQ#$Q zvA{#UA_x2@EV;bo-oH)I+TP3mCvSty)FQiv#JF?p9Uib$eu3k|f^v+mhezhhb36>o zYq$12W4^&}x=t%v;W=~5lIyH`&zO(w7qgI8NZ#Ox-0Rq837I`uawR04-QnD-n_>yG zuGdF56vk!8r-fCTJJ|2%jLP4bApV*0Q4F`8y!49wPHW8~UtPC*0cp%m2nzINR&f7n z!sPS$mpRi7u>cp}dwXhf5@M>}KDf#8qU8bmjS2JAo^R2Ijud-nYAj{kQ1U8+_c`0H zn$L`9wiMYp*i?O(?a z0F};J+g|M0-gJBQ3YqMMH#RB44{93bRT}S)Vu-SoUm)4f`^88;K||s4 z#8ZnwBd<|G&T~Mmxh3jL_dGngnK{AxkByez{lLPy*V7G+wDzVQ40^CVG4m|{ZKhvb zTbJ9-IX*w_?CXuX3`P1vb2YwJ)|WtR?I_P+Fi}{;A8f)@^C~qO++J?`dBb7xo*eEI zE3eNMoWQh0YVOGp%ip(Lp@qRmHr@uFmSX1EeLoq}qAcYV zw#`Q;&2k2vn_HL<{5G%8;VuXW>_4&MOB(;PyoSb4VU_L-<;)eAetWCTQ2AO3i>oD? zQ*JR7L?%R-UqJSlrF=s!quJH3)347Kyuf*oU*Y7+p#5c?&{APeP!;om(uLr((cr)K zSygV^C5{efME05YoEbFks?x1D_1E3G(BLR^N=;)pa-TitImhBTbNC+w*PY}X7lX7Q3J&5e@8yndcHZL$@%sifK^>{~N)cWb4Z+USjwoG`T z`QGXX{_8)sa2iLpweEA~A6HI8x~OZ<)i8jk5ZCHDFy3ic zcPqs5^{nU6av;g2l&8UN_0yV$iZ4;;P9JA;TY{RiCgdllUf)@i_&W;P`$$=|N0*`Y z;5LTG%8%~+o2@z_m+|9^v+dnK89uIWeRYog#GJeTe%}ai@IPKq8I#^)TX(?y3MhBA zSS*=huJrdEgT#hkMQ=C~q8l>f{^v3tt6ckX?Weqkx;pR#PEF2&?{^s(p42a}3QRlu z8|KGfOQ*0Mc%cB+Ym*OIOZEc|kg4_Ut;t%-+<~&rg8YdVmL+KUpoE z#~852f9hw(HLthIXYE|{F5w(Ihv$0kA2%G%vA>AjdiN&-Thw;;60dIKmICtz@x5KU8&W2n+4W5j60l1ig`8qI5o+?BSwdEH zTHNf)ztW755xsi`pgBHRbIwYkN;2i}%b@*FO`#eWP1(h|z`keO#7(*j_D>dUjFT4q z+7YriLp|!In80;NNxn(7f71GaH4su3h^K(hUxo;w8nv@SOR{ z_1U#{4S`;;B8B03)!X){3sFyhGJLyo+)`Ue-@I%{Tsx8(GMhAW} z%;8U%cl|$Tu0dnbGLL(&K*`zR{GNNam_!cndR)>B%KzUE$(=z$84M{cxr~)x!)ojr zBqG`Bwo0*G=E#_`{YFj0kLygm4E$A~@_mxx=B0H{tspU~Qsi}$N#I^t)Hc?L>)G~K zcC5;2%;5$lyPkiWv^R?#s?S-l`txkd-G9GtOl+9fSMnA-g9z!rdT?qy9+$*HG)SMwk{8f^jLq(MCGgAvKoxNt*+D#seF}*1lL{`Rs z?SkqQoNCArBX;I4L*-X2*$h$$Ugt2lu#0Cx@x+o?jj!K$A;rE2%akHE2jxSrS3j*` zcnwN%>sDCL_{`{Z={1v`nZbhNY-|N}x#!spEu?Rxuz)EVH-HXVoiK68eXJEEH{37=i5`LgA=A~XH{6`el%Ay=IJ(u6e}g2y=Gdk z1Xtc%bb7nkp=|Dqdr=zSZa5qcSUjgDXTj_JruqH6IjyHyc-GmXHlc(ho|-Z=G~|8w z#1?*&;{;}#Q8(jh4QM*Vq598q-?fr!kIZENwWa2l94nMBgETvs7VgnyU|r3dUu_DW zrb)YZ0@V39u;|{aGwcbVk@2|a%xBzvk9q&yl@47j(;sr0;Y46*h5VY;Hu4K<`gu)G zpZ>}4@x@v8=gb#4Ud+8dTX2Keh3<(jW(MWQKZP`;D;C_*W%w)j=G%RpgE`zER$gaK z`^>mzdg_|3S8^E{=iEQDUhM1d8J-zGav3+;m|ajPk#<<7>??O!GP(cyq`F844#k#` zi9vB!*zYh-_{`Jtb8VJo8OsLU119+?xr}WG^`zoXZ5CT~S!_e;3fr%BQBhqPUvG#p z{NbGCHzo3IJ#^M^mWw8DgX?UbhEI1s@Qa-J$x!%m>&)x>1!2RncQ_It%O@CEKM1S5 zG+DWQ{~L(&EtXwjb6BUe_@6;&AEGKyK7D}GgsE%A$=UcaEEg1&&_Qso0;!nRtZmX8C$;;fhvVn`{0ZFDCJx=mA{5#$ASxw)+;DA~~ zqCqdiBhs+>Wm@!!?{$Mm)vizjw+iqwSbB7#Zc(MP^jJ!`?yQL2p z%m58LD^zRrHdt&CpfwuC|SHsggIUo7^&JSlwUINPzM*V91N zzFXx^=>v?zN&%HO5#_n0$UVK{H`aw?m#-6PKZ#FizEp)T|dBfp) zefB*$=^I>AIX$c)lj88>LctD=M2aSyDQU5ThOw+}C$Mcs4Kk96Lws zdTyih>;;>|QusUK`j{tIZ@u?T5#sw4h8s*5)J3znZ)nW`_r;9$pEFxr&Wg>qvj8=p z_kOOL$3NlPKF##l&vPIixWvBHgdxuEqef||xA9b<_e@|vp&a#Oak9yq6VzDah;!My)xh0bham|qop05m!AVy5}$)vODqerAkW z&zk?4v5m<{cBRcK<70B`RsRMxftK-h2F2yX-&@4mu$wXGh3@e_hHb1fp7pNLJz#Ld z^{{v4HK)>BOxyUjF2CpR?Jc-g?lx0H^#Y^7wAk`~u)8Om3g%=!khd|^jLAY?K@X+Q zNVGG%prL=8sp!?$G?pa{>)g_fLeK3hn+Vkyq{)0B?_!}e;~m}!;rTzGG0(^ZH%Ff- zZx$3-bM4VV6DIJAxiw5rm~^7+`p(2|xChQ19#1vi@-(;=fMzwGTz{Qm*8uBoJ9t#Z z9yrccWasdH)!wu0H^eSn-gvm9z2w;qGstqjsY@nVGrXDKu%nz=(?1y^7xe_) z;@=t*SZ?r~*#gv1Xu76#o_)h|yK|9(lIhGh;<+)pNSdlwZ!k{S{AflQ^Nq{V2Kw^S z86_bRRk_c;-WN-lGJQ9r&a0<=4WPO7-!%+JYp!GoFReR!8B*FGWlG?=3d&`x_3)n-_yli8V~G+QQJX zw{??KE@L!nF{rhf`|PVdC~Ja;z2<@<^To{T(a{Y|I~b9duLy}QHD@Ri^-^B2plWgg zs6(;-DQHm1rRwv;Bs+&c*^O=u?0d5rewv}Q>;eN$i8buwNY}2|rt|d1zuBrYav86E zy#*NvgN*Ef2D0M0ug>M40B+wcM6~asF6rBY`inYKJ}LbOYFp0mqtYf~pH5-j<;mtd zWUQHPAcn@OR_~EsFdsbqWFRm7qSl;Y!4ByIDl<*ruZP4))6z|B4TT>~m~=uWnC_5K zbEw;k+_^^|wK%yzGid)SQAo%g49sL!xbC??Fd$&BXTj9ohEHeN4KAMow;$ZolocYN z1LN7C*4{5Eh0Pmte#*?;od8)hG$CQd8J-5Wmb>03e=@XwHuGJ-bPt>R5fk}_e%>=u zQuk^YK*Py%_%H0v+Wnc4XG@$Q5NH zt-Uv!cS+u$zP2+JGzyJWy)w<)-B~!!OW(uLfM4egVxqMsUA48nOMIj^+DpQz7=w z2@GORnDR|$ZH@V#pPO2YFWxJPlVUvPKBYL!1-bimbq;?*No)DyQpJ#ct+#IP)Bjop zS>tl%_H8+z^s@kjy;WPCjA5diu*{t}Q%wG=f*G=kt^@hP<-5k*5RJ6hR=b*Wg)M)TnvD;GfYJ+lS zo+;CZdK=s- zEkSLRvJW|!%kcMVEM@%g=t*GKceR$!j786RpRwuZ7fkJCILH2B<<*$=XD@sLwNx&I zYdmNEaZ$^gac-;oW$z_>_nro~NjMsW&NFPdJzL^?4TJToqG!BI-cJU#ybt`4DX?p( z-V2(Yh4knYKG!g)XFHZN8L+dfeSg*S*bdTBVR7gzXAsev@|m$`Q_I@z332B?Gaj{mGN!ws#bjaw~GLL&FK)s>VfhO|JTV8eCij6=g6yu@s!cpD;%?XRq1T zY02gec6;aW3-m%KapA*Dp!wT~ZGRAz)RZY+XBjeBo-;o=kv*aHGvlfaVlrnzOITt; zw>dyY04?MjJ~LK@zs|+6qg(+V0W-TVj{2bCg1PTJ>Gzr z`~#rs`N4ukauWkzo|QXumx1|d?;g-jnRjd-7KMKeZ}6KG`IaAC$ar)vU^HMTv-jDk zt01?R^>f-Bet~&m-Ict3T;A)?B07Kh8@DlpgJv6pW9uvTN;1wB_`@K5>GBiU!nq)( z42BeiWuWQ&XJ@h}sLtv9HtqAVEs(yT&sO3mwOn}88Bz}9Td1U zSE39+#jbqdy-2}1{10N*{|!jqJ98DslKQ)Uqabb2GFOQyTbUayJ<6*+()(=BfOCk_ zvMJUKaW}RwEA-|2TYzRfSU-Q-*y!fox)jniJXUEbe_#@huHl3e8e173Z1S)(GjNY< z)=7M=J7Ib*<1w&iO#^5#?qQ)r8{`6W~6wP2HD>w~2czPx0;?rpEhqh)LDsjz)*(@{elU|;iYYMKCI$OLX z7#wuWFBG0<->_IO{T{ek8h(@GK~2Ms>v z5xd=A=gNb7z7r0tbm4X|<}NpsSKwP~`B^H3Gdlm^9#C}(Da>)TO_6slcr=7uVnhL+?vhJ?US%6*&fJTNN@45B`c?P$i*W{GQnVnyM+;Dgx zx58&ii(&b>{VbQ1vZn>iWqoj|IB< z2YnKtLw!{;gGAVyzipbu2E7dxUv6EALK*`Cwcf!UayN(fpuJ_G4KWR=r_WE^$$bsn z^7ELi`JAW0jpf4Yt?gW_&8+l zx`Tbyc4me2uZ1<$kIL*E;FWwsIcW6D(bZOI@uk_eGj=nwoszDMc2L}VhxdW&`bTZ5zu!0A8kKd3PW>VW)*nj3-K5BC2vjt|$lf zE}9EkOcVoMKe3JB`up8%xr}A&Gj#)coM-)uP4An#0+tC1Jv@0E@_2F@67*heYcgj) zvT%F(3fWTDPi}j~R^_my%*SX7h;8}yarULy%fGm{F1LG=9aUfXw#z^x8-3BzqlQV_ z7<%4({-xUY?cGn1D?y_v7csl9ph@H+I|rGlw}J;iwc}MaufMaFgZu7Bgr*uX?DFn= zdFb3v2Fp+2jMccukeAbh=UFnd@8>jgra#%H+{*+nXuwvx_cnlbTP!JUyRts+Avhtj zYF2VP#7p?Hb5xv8zxSZxHdE6K{sj9gJDO^iZqhxlJWhPZS@sQa(>7iU+Rn;SP-nb9 z>gBr3zpZWe#RQJjFf{LxlmK^U<*$ObU+iuAKBGlI$Zl$Fv6+;x#2MGSVFeA~`G?fb z_iXz42E7eBfo_^}Gq%nRx0F{{F8Dxc#%D$?1Nj5c66Ehz*xDzn$2aQ1ivcFg3E9B9 zV88IepA3Z=XJ=kl@V-&S{p@KC!(#9N_xk^!E-hr%0y;W;4`bFs!hv~#c-}MSBgKbZ zlojOmnn5ZH-RGi6E1<+?^9%fsS^(M^D&?@RH|0dm%J`kOF_1s-qTu&J)+@7FHiLsH+>OT})%zSes*d2&!bcgg z?&rzb)8O&-=5&nCu$E^k55soQh>1;A@iWsCkDf<+MZfZGT8*VsY$<=BKP-~#g$~jl zs|j9#rz9DAAJj0MV}Bu;@MOEwq+kcmrUCUXxOSr+( z!r+>1 z%Mr8K&~+x~&mUC+#IkBZtImXoA|G{jgeqvi<-_@q&I0D_tJa&4uZ;2A)Bc)7zM)h zmmaKXaH#tGX725?jD;`G&fK0jt+6htWJhlRc#%vQ#*Dqx>$4%Q;ELx~u>85-5#RP~ zd(JG8ahLJJO^?a!TkGCD1WgrB&{%PVui*|W`@?C)b`9El6W6zbN|DRjH=S!=5Zl>y z4623`l1s}fHx)y9~cCa>dTW~LfdQ__Bg3TW?syer9F9-LU znidEdGe`*r>n|;`b8xAuZod^c-8laXXbl|7Alr$>uyr*p>#nojdTgJYbyjQ@WJzar zDQs4i#qnu5!xt61?Q=kb46{Hk+VEBE%!c zH8XEB?Q*@rZ+a^8`<-LA#S)%xY}|F#zlU$Te3ZjpPlMY`HLpq;Z!jX2hZ|s zwR~>&XIn#roOFi3E?$Mp6V+gaiivVdNPp12nWA?YFHHV%r)V21%RBp&5?bT7?5D_{91A}!;?4_(DwU(;B}RmT&kuFeOvOb94o)< z0$N;!rLO`Vcv7lV1}(ds5WL=8*@5v73)04#IYC~`4{mn4uUh@S>G7_BT{HO~;BBKD zoo5Fx!Njp)ztAZ&onc2+MobK}lmQJfW0o?|857W|)0!NG=geQ$-MU@V09r^P>t?eC z)@+{>cuSU{Ry^wIU4|U)f|cfPwhG`ZVIWI-)c5XhSvEN}?6)&076r0HjxoH5&6(QU zumjxl%4IwT9jE=t@Gx`hjO!0jIxv-=yF3dnBdyy;pVDnzIfZdU=|#{sX{qaZ48Oq@ zZ_wOGLGYa0f#qSvd(E6PS&l5+TCt(~F2mW&+5F;I4h5Jr`_efE(9lNCnOT)~4hnng zzMWgUTJ(We{<%H8n|ZznFFq`rr8{H#XU3j>jrVIf&as2Xr8nO537zt8*0uN*wqFbH z-v6@a&~4$wfNOo=wX5(6UynS{h~}Y*S?^wiRhlzAXWnr|`dd^&w1e^2w$i+tVgkLO z(V!JbLwBDS&QJj#G1J*1kmORy%W(Kr220KvsmXD_A@gFapEl_p_|Z}D<<_~`cR4<+ zzp69C&0#TUuzGr&HE8Brp-K{|jegQ)CvSsH=q1oF@QyE_fsdtDo0T$Hjx4^u4ZQGc zz3CY69s<3$umlF>@%A;~_))s~@u06Soaobg;wy>6TZ;;QLa{1lP zUOdBoUv3;=I0xFQ;gDT?E*CVGyJoP1_g_Cy4XvKaJ$Rn#p~`YRYcLKQE^Kcf4fk`RZ*wXl`Q3qbau- z4D=@CG9Jr%d@1{Pvngm&3^ccaBhQFsb7$;}yB(Fu0@<}U7qPrHWXdIJhMinX1ryeo z&B@o)eZEu>IuM9sfZNUC{GPJ>7@jTbP-mJabOp|oWO(=c;-i`T3G(Kj86Uk|`lO~| z?fJHY55GBcuQ2C)hGpH-1kmVUZaHW_L@uMPtaVR?M|xjwae$oSlBSR}c87(BEaeXb z=s#!PaY_1HR7bVBL*ZV=_n>u=pcT>ZQSR-hteJL5ALx!d8oMHK)0OA8piyEE))i|Q z6Jl&`Gxc0OwsiVu#y2n5ZW0vOfYE@Leg<0m32Uyfv)H$&Ecg0*OBGZ=O_>26UuEiL zNZ$Ie#yit;{d3q@At=ir&u>AO?uzeboVEPA9CYa}QgeRFgi~i2GF)Ch2d(K3nhh;h zt~G&+mG=SGuehG{R_PYlIZV@7Jms6SZuk}1Gd~&5fhImd3wsHU089~h$MAxA`~4uo zg$j6yq5E31z*pC8-mY+Q^6*^1c!58MyI^5a@wFLWZe2eGny1u-j2gBqzFvlP)Nlji zqWgz$l`e-h?^TL27f%$rxR zq^kVPPHBa9@NjANIp|a%XkZq)h!od+n95wGuixrH-2)-PrKSuqV#l{Jh-;ar>puT> zf6YM?rbidEgSA1KFWVBd%4p)fnjJH6boeK5fW~BZc}Owpg_J#ilGjl2<=L6tlB^pf zE`&C$VNO}g6(X{p(P2@=*+hvL-%@;7Pj_pIc<5bU*RsGD=NjH{9hCFz`m6A=u7QX1^`B{nL3uLso|ZJ@ zQ*%bC=Su|z+QX_V?(s6L_{Vj@(`fZy@M0gPr9o^Brx)#%KG1sVY~C3zG%0F!0=p$eEc8PM?ix(Mp!-a2#hwuwMc+2&M`vC)k za@UhGLwSV@TN5Jndl}|e?UV*j-l6Q0Kg)r({IW67llegELldT$6?f)beaD_XJ?il- z-U){9vl-+V3+6L?UpnjF_TR1B|NGwI4diP&dfDx5X9yqT69Emr#s_;D%1lolyo+Pq z<~GAh&e^2WZ!f)m2^!Hszx3pG#^+VaySjuR`d-mYnIXPm2( z9`oNA6u(Rh=dd04;mhXmQh&%b>+PqAbmQ~lGr-(7CSb4s>{(n7a z@W^-361D?^Rtc4=djqP>!Hce+oVaaK(aW^oOX2P7pq;t%Ew}G^=3%t)$fuLJ*6Z?L z{%Kj!$5OC`;jpp14s?&g>)J*)2a~E~BgQqa&$9P6>;r8Cc3q!jhZvRVNcPHONYOCn zaKBz>(A&@(nS3eQ;dPb6GwmYq^deem;S?romDmo`R$f0Y`}Gi1nq zP=;dgLNWjN=285a+eDc2ZY?01l2{Ft%T+q0+PIct$`dieGF z_q8AYsLSj%;p|bp>?XfrWglz79){m0JbEi3Bb=dTOsDdhOwM^LFIZXCY`Hz|IWzKF z*dt0y%^7~_o5X-tp)IhidMmV0a0Ac)P1wdiZrrjn!a|9DOR8im+# zClu^Ih?qzMryulblZo1jvCF;o{!Il{F**T~%nSZE9ey*{7*r5PPy@c|fD-`5UX}U|;CY z6=2D{;QgY*cX<;)UHygE6v~58GGmHM1DOW$TXATf$)hh zV(!WZqVBC&C-|?NS>n0wO3*qV%TLFyhy{48&s}@(8M6)N0};#QxZR+gbgV0uFkYzt z7;44@t{-0C<%n2$V5@FyY;pPiAARkzQ$4-TO%gu7hxgLY`3*I!2R5u_N-|=)AqJhN zHRXO*!yv_2+YVisi?pw>LvgAZ!!LWzhf<7pcpo@DeYQsKi=DQTIN!S)1yB8o#n%)B ziLI15t-k&3j)m8LiZZG(?YbVuDEXZENbTnQ_ppB7gS-Z%tWVE)H-DXoyp`&^!poOI z`Sn)=J6k67TwutkP&%~7161)Othx4e`;YgxXPt~Ir z{7XBpE&Orhwf&PkhR5LP59GqR?dGo&KN(;<*kIGdrs;};msr3v=iN&RgTIi)t=|h`@fc1vLzz<<(0+!p+_wl?tT5mpUEO2k82fYD#LOG%Mb3*MqmS5gJ2H# z4WlcQ1qF`me5=0sks*We_gx*)e?4t96xRF6{*4T^l|Jxq{r@JnhKqs^Gq%o!beZ0} zIb3)?4RmPV-q{RoN<2>Q+fS*?EWMjAGu6Z6spe9~4^PyX7gUPRnj$tefN%F&_Q;phvR>4>Zs$Ma5w%xD}2?IvN2N*m;6+8F3 z)b2pdm6SbK)>mH(>}&~{79`EEuKNinH!y@T7BQxL*jXKaW8by9DyLcJPY3hKUTezT z$9Y1N!HwZTUW1H#3u46FaI0OBU4w7w-=|;GSk@q~S@w9kqJ{CnrVE$ev7K1E-N@|# z8$y6f}yuT#vQZ=IypskU1pg!SNdDA zlaX`pGDNqxAx=!K5bO#z$i5evvtDJ11!zZ#IB0`c#z|j>sk5zBEfeQkZnw$a8sNl~ z`f*EO{#28l5(gHp|G!}gXzJAGGpJ{{pYwu+es9CFSG>>eRL$l8APt_1wgsK3s^$2U zhv9nb@f6SwuEH0SJxU(kyFasj`PD-loh?U$`=m6r#GOApyIz0dCj)FjgBwFRvq)gD zO8yg&6gfDGCcLqu#RQ^KGr3cK7w$>81@e*|Fiz?LQv2 zUum>^PW|h**_W?v*Z!dEaGmkSqyFU|~R)5E5%hiuKJLk9t_upIZ`c=eTEwlK?*rP;aO>KuT)P8^Loz1>1;sHdxVM@x6+O{?Tf0zwGK}KT!mRTuEM((1&_x(FOqlaM|D||)v@f4;f;lV2UpuG#XlpB6B?00GMgdPy6 z$@?^)kt1z&$gyxPm-XEbJZ>?CG0)hQarWK+BmDncc+TiOzhCux*12o-w{D0vd}a9c zBRV@Tx*_o1vMsC;cO2wSm?}lz^UY;se6dyb%(jNcPpcrugQZ1-whb}-Ur zi<9m4H#ZA$YVtn4&ls@a(KB96p~(-HWtyIou8nT^qvmP#|Go6S;^&XUTm^#!r}@cO zPAI$jPx`>`xc`Yhpp9vJx3gMY&yHSw@gAf42Jm`3%bn5+a(llOYy=g?s~OHYYe$Ajjsz1^sWh*=vkfre_j8_!}f|lZ@#>9TmEL|?ee++ z(fGs1?wb64!}j98^*V8aqF0yx z6>sSO{)-j9ZoD_gjsdhR?X)aoVaC?>?>`y3ey)JeyL*I8xx`TMXtsOCocun?c+DFx z_aAMVox`$cnbzg|6(4i|tG?qYaMJ2!+Vtee;{94ppBZ`T|BB~ugWGP~kSFEyXRPf% zYrP$G3iMrui|-N3%T>6%vKgLe8JytupCRt~cn_l_Bh#Hmm;Kwmw|wb2=xI~LX@$Z6$JyxY(riXsDX4v)`Q7PU#af^wkIaY%4 zX(YD^)2vnIGm2k--q=`#v}62W5GS)j_{M{KEYoV1Dtx_lB`V^p{!;J0`p2rYoYwjSJROjZ%0y?eq5c+F9li(2x`?XV90X_&s{aA^lULGHZ8Qi ziZm3cIX*vo`@vKj|No|^Zr@qXTH^2|I{(hY3C=$s&KLOqrsCb(|2IDFoYv1=CDhOt zxBtwKEes0JRz6VE7nmG3yR!7LFf-4mUzfAQWX@b>YKrB~5}NZgBfBb>UHa~0$$d37 z*^0?!zpGdsq_bk{FV8%FwY2EA8K0MmiiOrz<^=&C4}J}rmSXOZw|6FAl~3@`KeM0T z`_{17h*Oa@!szXb?@C9nGJL!LYC1Tw-(^QNpYV#g{3cA~+%4Tno2_;;ZsS|~tcGE6 zwT<~_#<+%4D`%L#*W&9?OI>z}t>Ggl$EQQVsmcoy--h$%MgE_^d;b5=SNpt_TOtxZ zZV5ar?0YW!=ngw(uPo3K8y`Lf7Kc*S{AM8DLsd?`GtKa9PazuNoIYZIh zj)Z$M#`}T{`qtfjP&en1D$~zX()zpJnz1hxaFW^h<;Y^Y)k@(EHx{06==sd}Xy484 z<&YIw(1tX4_?`j#iL3e-7;ZGpxF_RXJ@L%y@WSa!PS$U@K7WEmriFrl##_)7>ff!b zPcCOiub!CtfJKTCbi^RIC$_bn8&o)CH=H@P>1lDwyQZ&wUMfpgPPxKh5z{?Q?Ec~q zqkTaWs<_$9ot{21kFEL5`sSvRO5T>WYqvc)E1lMLD)U8tfG#M(Kiaph(VvDtHcX79HEe%6Ce6$;v4=7&PLNeDZs0pRa8Z{LrOg_kUUU^Z%D7 z`Z#s4PxF!dTj;Q!bwl=kM?*zsw^}3SI;tZEb!uhf9LtsCVz@v!Fcm z_{A*t)sM_4mGsnpo-=>%69*~9BgWqT7FqdgA6Bi{vp-P*-YEGCT4xJBtRr!oo!R?4 zmpDEw*v{M}?=&fRY28~PcU7TBJxeOOB2Mf#wmFu*Dy>eHnUT>pJ5qE<#mimO?GEz) zZ<#WE?!MyJpHE-Q{UNbH+??l%?tl0H{u2BzYR}}|+vo4bf>`DBwK(td`e{F|JK3)n zd;ZtAFTna$at!w@)7^i!Uf?=vvE=3bQpCZP=RjL*kDI?`DEMd_Fo!SmSU6YE`>-`4 z7uWB3xybsKtfxW?%ZW*v?)8iZzBBM-OGpHR&Zg`B$pC88UH_lQ@OY2mTMmw2cQ)xN zgzQzl%geyf&tw&OZpXiLP=eH2YR15(Hvu%q&dAW;dF|Mq%y(<+i@$7Yna~hvl>4R8 zrMDsD%dr%ui0hy-p|*9CE@;HKCd10{TuwdcXtjuiUvwFI&Dld& z>pHOi@QmB5_d@yoRcE%`3Um8E!a=cRcJH6Ln|j`7Kf%k)mY&1SkaYjw?5`_ke7SXI z_ex!bJ=swg58V(mNQg7#%v%*FesJddk38{q`?e-r5KD+&|G-1#WHRVn=a*Xo56e#R z=n(bV&#X{=(6=gQL1Mw@eKWa=JNOi2ndbg^*8M#Gm5QyRij~NOrNQq0?XMzFB&BQy z&G7u#_CKxXOP#NFVRS<&Xw4C5J5+A=*Kaoth%@>em;Ag=&~DKz7PqH74UOFC(GCt( z@l!)?-K#nIc7IU`7l)$Y=TlcM&i4#i&%7aa87%AT_cl~)dDe_LNBsIvhI%(8;iYwV z-^)(*=rEjW%D|>K&#fVSufM|7z~3=Nzmxk;skKaL2w&dwZ@J!!T&6dZe=1;ICt+u{ z0kjkZb!*T}&Ei|N<7*Q<3M>Km7Qy;1w@&Zk+6gE$pcla8KTTpo3Q zp*h!wi)YxUmVj0z|GUNX>E+fr;XOLc`=fZ9Gg(YdMZb|#|F;#(S_&SI%65h*$#;P{ zKAoo@YU*9!u|K)^s?YkEjU{(ayMH)ruc)G}d~Rpu%X7E4$#ET>`tyEqY%C{_N(7hL z_1SMZl0hf4tUe`{VWzSnCVQb(Gm|msWR%&(d2d(r7wby!aG9L4RyfRHEBo@+anPwk zvQykW)K2YYR!Co(!~H^k{q1cGj8?%fIDg009FK8V6?D=%psg-v8_05>ae_$oec0wf z(2lY>*Js_+de8RaIrsqNGsoGg?(;G*?01{wGwIh>(60Ikb}RcB6C!kb8~%N=GGvPL zZAiXf^6^i+l~%JrkYb$W#}|iP3UtkQ+0Xpn7tJTEus=?U(eCRea09BccB@^~nUmWZ z=2Y#KWYla}J&|XY(%02-poyftE2c3{Se$i^y`Xxc$kEBGj3yoa$DwFeS@|iDeY;B0 zY_5AeBDLq&ZW0XGhjj8vO~3FO=!Qe^odKvvU@U^g|1j^}{EHkXR!;{lWyltLbC=`Ae)A8hfp?a7Eoa!Hv4}e~ zESd*PP{o*avf!89HAp?LnI%7`(C= z{`{40&;D#5v03+kPs^6_%;)0#Gj=D~awr}VTwk*{H(K&mp>4zAs(*J}qZ$2cFyWjMH6p5?62(^MM@}QjB6>(oC3G#NTf|yj$IS?>wtXZc2#{cT9A)Zd%CtDh79dbi&raar?qr7u5(uM0RhecPYdfBe@%@9|D}3mzwdPWs-M zEcij1W8dzxr>>fJAJu0q`IFduolo8@O5riX-x-}1=RjNC!OM4f8T6bk6)l4-+6;2^ z%4K+>WwdM?Lv~ee_S^4DD;HILvMYXXalKJQNJHP>vLxb`<&U*mpQeR;Gz;7#%6V_m zfqzUr5-%#aUzg3~n%DB^y+9?;h2xE1KK2Q&{Gf>mD_w#G(X-0V-uZTC_W!K^XTGHU)l9y3k=4?Tx+Yy&;hgy~H~Wcv9Q!uCKe|2ssRt8h!*liRxszY~ zDqne`dg068Ay4)$y0QE|^PhEhMGj16y0d!K_iH9Q%U|*}%UN_BF84Y+Nx1*luC5rK z1>2s!&p?domZh-V(f06{s5K<9pOSv!*lsnz!co1&$ZmCOL>}BUE03 zj!U=}*ttY0?_WH?l5`M#t%m(T9=o-QWu)ozv|In0U4eJ ztPDpkHkIrtGiRu9aCsMcbbULI)R)z==X2xVy-@8`pEk$hm&DXBSq^PqzW$!^jJc(> zcY&ZlZ=Ai_fvVOk?Y5oypuwia!3GMqu7ySa7V{Cg8p+TmX3u}skm&|!;0m;YB&J_w zt{P`-%`uyZy zZ?k2ldU$!I@;1b%FIeK%aM=8q`tAvJ&l+}e&nx;Ow1m^f{^w=uReW|)3yw4VkZ4F; zW~p?S_k#Fo#y>(6q@$z1ev1Vi@UG0cgj2`%}i%-rsh_Ab9ZkwjZwWYQSEpkD6Jt~w=u6hNh2bD!xwXt~D`Y4r2N-V2vDIf$~GcP$1DT7TYS!o=|B`h}&|bf?Zi*MlooB>HUJ2E;Fy5lii-;kiqb!;WJ~G z)+;ypH!>l=k3{S5sP%jpvLthl&C?5s(pK!-8GqO`)H7dr^v)?#OPPV;8uypynonk# z&XqNnPxu%ZcEt9yn(W@%bnZOH4;LGgEae;A8Y<)dyERNNUL7TG_Ip{rrN(h9Y2Su+ zJ?4~{d5<$ed*VQqw*wc_nD-ES zcc-V?Tr%x-a;Kj^>pgU~rhy@E#x$lMo=iXA-__sqRnfhuBX!=&`pgp_Lf8LwX*6U$ zT4e4J2iK-bBdv8MKhrTM?9G71WY!1v`*(^J%lVg^}ZvFo5|J~d3bV4`^&lykdyJzQlIa%?CzhG_p zwa0gAj6-+bWw332-~3?Jx{?o})$up}ty17qL`nJk?uvhNcODgfZe#y*MYE}|dh%nl{|DFqSL&EOAxN5W zwaAaaRWmk!Z#6mLlzwu`V*juQIw^nB_11!t3iMpNWe=8mE^_CyeZ9=yYSL|yFyGwU zd#|nf&U|CBhny5+ZEKNvgZ$oK+!?u>&VUa}nsj0T;{?HT>=`nvx;}s3|DAq{i@Na(`6=V;Gwm zXXrg+u87|bTC@s26;NmH?exo0pJocCZ~K3@@yN2x@1|^17O}eTrYD}u*tRgQ2zI=b zI?Mg$CCk0%S_`fA)8uV9zOQh{ul*}#vsUMGYb^VIGWVML|2@w|YxpufYz+TxIM}@O zTcxNa;{p4lr3+0AnD%6!{agM`y)|aT^Ny$HUVq>DxnJ^Ngtl^Q8*(?x1%oH~?%igZ z^Lp!A(^o$k_&Z(*9zM74rK@u=^U0L)xO3&Yty0+Jlj*&%g|gt zVJU;3`=?J&_k3TW2b$0qmzXU0AZm5iug<^vksnsyVd@N>H~-Ey-&L>ou^lz}va!{0 z-NEXmdE76aMKYwn--8LG=s+d1p+a#!P-to(!tI}5cjvwb} zJ9Nrwxucut+V}dtsO)3H_V+5 zYUk&0e^?Y=C@v!W>9i#?aANYo%cUD&v1!>e~*1mLH4Cz zp^fue80~)Kd&Y>L+VPB8m9~txX zF2ha8R=k_N4QknU8D3o9Hn;Zj)V??~323Y2TRG#sC!ni&3hW?TioQ0SJBU~<&e`E5 z$*``wZ>8>mN%!Wzv;W?An5$D^C+Om>dG)2Y|F51jhwFuO*ORW7PLaKL85$V;S1s6g zoOOGwa>Lhy%Z|vfrktFzIGxEt)^ZxpZKhAJp4KqTkE(utRdtnw@t-@h|6RZ6&3b%R z@NK^X%VHVM#&V;qcVn;=J=til_4%9}lgd8U9}W+n<=xU)?^y7E;ZZF^t4-?@eD%)% zeY8a2yszD($^RqF{?v)?T=Sy6Ug7wyt@2qDiXZ>q92=4Go}23p|55)qtHdkw<7U}> zTFkdZ(3#~z?796csB8Fg(LQtUThNk9 z#(;CO4Dt3cwYw`sGq@vu{PA0T|4aUzMR!8x&9C`!aPG<&g$e$-|I9bL^MBox?|5|Dw*K0mtpTv@w$afIP6fJ4Qg>ZhUw1ID^N8?MQwE;Z-7kAL7G@OR zVhS;M-mtUWNAF9Lit+iHzaQ={HML|u@Sic@^-pUlM(NGRmTYgh#>=4hQsPSKv)O6) zH=b!UNjk8ob8(q>RQ7e|UlaFO-Vg(we*$W8n61}!P@J*#?Stn1p-*)WuJ+Hgc3SZH z_pjLE>rNYgGTghqHW7S0xJ%zZSloEK=(gC~J#RPpGt8K5&JId*XYYv#+y|X(?3=+I zuxFXl;NJYg>D6zP*2+hxrQ+ zkH6ei$>Vab7uY;%ob-;tBB$F__t@2A?ivJtJ<43%W<9~pXwzxd?J>8nE|q5pc)=X}QI=8clhmuNvUB`jA8vQ?B^4Z+E2Lux(iXU0LAo zX7G{W+uy!e;BtvYD~W+2y6?J~<*9{2s}J@%uieJ_=jg%Al{Q=_j32w#t=xR=ql7~I z!D($d>$W;5FPL(>zM6fzLrRf5`@d};jszaJnB-=(;dkqHyDOJEzdIQ2&sg_IeobsF zr_}x$i}&1hx0(LT(wW`U@Tbz+#W3^78)^Ifzki0WJvV2CQpw6cmMvQ^>=&F6-|{^> z>fyGAN1{i5?0ayL!()L&dk82(>kc|r9x)WnWXuStGPqs&hTZxO&#!v^9Z&lHmz7v) zoeaKj@kiHFcc)WBf0~`f^(c=0rVY7_mY=?HgeBN1#NU5yz1{A;hm<4(sDQgTnbDSA z`nj1%6X^83C^->{tG}BHqYqqO`QY>Ixy6rd`)Hq;_?3*zh}=nn7=4$ zx9gXx4AAWa+ph84yqob?_0p~SQ!}sIJX~P&s3GVbgMk#UNKbdVK#awf2-VJ0oB8~l zRRbO7%`7;wW688T{0|ntlg-$z4aydgpTZXFIy5M={`zo8*uD4iq*;3wDmT0<`gitz zFl>R~gU#b55y?0gS8vis(3ro1;*vp8Dk##-Cz3{7_Y5((IWW#&Jo#K$;jeQGwK;OeGl8u zQKlFci+*d#T2NiaCD-eTfT@9vEUGe0WZPiNp~`n1yK`0o85Zp0o;yz?ggY&5Gf!{W~jEg0+H_CZOzGdcqcske5HEg14cVd1sg3x<^?x6A6#bIwj^xz z+kNj{x9b*#q`j=DJhkxfwLA$E@dMLypFXJpuLbS(%;#In^igYtt_?2|m$ zKxM6>){dZt`b{&<|GWGKojCPp3&ZoO;-`Bz32*hZo_BlSor|YKo35(Ye>=~>*IA=$ zQn&TTx=c%FOZf#Wt6b}tR@OSd3SVCkB4xPX)BPu#bPv=RXM`_*_h!MKpigI(9KOFZ z?eGHo!;jkQ7ck{A#{Iw6vy8do{->Fb?{GDE9*&Bx|9^;c@%={*LMIRCiQg%@|LN=N z`)gNiZsfVImDZBWn0K4$(1qEy`VG@3ygDKH{N9(;x*yvc-*LI@7TdHps^yyC)MerbHaYwky>L9| zsqQ^72jzqX;1$otiWACIsy=k&-?zD6^vY|IwEWTyuf^<*y&foB(NMq5bY~l@$@bLk zJL33r8GkDteP8k{f=}%m4{w8o-%Hk41n}qNcD)|KgewjahcPf|HRUMkR-SML` z!|gABVhzN-9$#4Xj!k2);0E5HGm#aCR;&gU0CyT?CN<7IzyIsJ(%9yEtS`1RmWcW7 z=zQeX(7LCsDwbt-w(O2W6Yd`usR#^l>$muREZKcd@#jlTFHfjln^Sb=9mBS&W0fbe z59;K9?O_mq^*EZFZ8zha7qUzbwB{O?Et|GQ)RrxS;Y;I1w*v{XcaskGISnC%q7Ot8&6)xlK1VW#ze_SeU_cj{c<(xa}-wD$$mmqmPUZ3>Q5n%_&Rv&d{& zwzWR*M>We9m+n2;{t&X2%^U7YvLs9bP+dsGE zK!w%6P?!JA2PPfjG(WhlVNX@>JGMD5&Yobt@TK|o-0IIuz(X~Gohs5z2K4{ z&;EIp>B6l!#jjWDDl~x3X`LIraHGV$KXLxHdHL*5C$L>$kN_<#Ydy95pUt}oVeFq4 zKbfgtn{&cgxbIKnsu;cmfrhR%th-kozWHyqYI^2vrfq=_PPI;UuXBGbpIxZXn&QB4 z7gSvB;4Ttqn1A5WrUil=>Q`CB%92s`FU?UN*gGs)Nzu$NbWJ(rz#GX1Hl~ z{$t|WyL&1R*MPQP+L;+FzqicBtiV#pGUnCwzxM()m#{Cr%Xs0=+MvICiVXDT-A!Pf z;N)gGK}OWS{_zKY#fkQ>U+W6;`XzNk7LgZ}P3XY)>&@03DxKos)=>3Va; zrTN?vYrj^#yzXCbWBRY-<*XA-tYz1sa^{=C-_CCqklB8Br+IC`5li!ESrJbu zWv%(I|4#nU!yvvvcY(dKg4*6PgI&i@uP>EY@Oi7Asd~ z(Ua(B_X^vUXF7iJ*qeL7%==o*!lwT(K2J&c;W;f{l*9DU+psNK+gT;d*jN^pJFL5r zyj%4CgkN{B$pyQA+We(u@i#wVOYQ^W36+r|zoWT#m@3@c))1<-BjLBAa+>$wQ>8nX zOuVBr&n)Z5g{ap~41aU}mv4>w#A^Qky^l{5^Mz=gh);4aVprSFx*&Fe@xlG4J(X|! zyjIvu{l#{D{p%HK2W|Tc7F~Bs+*D|tm0FSXCvi7VC^gm)V}<2*v+D%GU{JZ?%R|*HK*QUywG#Vd$;t0_YX`wpUnRbUNU-;yG?zc;im;Ep84z>{I4Z@ zS-Yrjzo+${!{7%0gV@&%mGYihRz=@G=hq**czUu-j+Dp`T?YA0Gq-O6Edo0GRq8Hp ze}>Cf28IR(PZ!6KoRe%C|8{#9eDiyL_e;~>&CWg-t8Op2`ug&I=8EF)=XKtHK2aXB zp}7^lR45@hg6lzrW%9kNeM`cpdA((L(fR)_!_pc26PB!;_t+sUz{a-j$wJRb|HMF7 zYPva>7_p16+?!}sh=1Y1%FMCgRue);4d-9w&HUG};zxDJ_wA~sldA5S> zYe9GYhOfVP??9fF$PTVq`)YsZ-~Uot_v8EdRbO9*UwC}jKB0EL`@Y3C@t1r4Z92Sv zS>&Z`|Jpg5mELsS2nl0)&~8=yCK2SeqIV2G&ijInfH`X9#`5|FpTL?QFQWDKUVbHZ zC7`0{klU#mM%MoYf|&lul4eV6@Pc_XS}g`zXZ}n8GX-~ z*GyxXKjqgRd#g#YmZx$Vo~*dETTr0&nr`#ciPu6`-`{(FYWehK_JOazK3Tc6ewRl> z{;hLw4{>m$>&|EkyUAg&iE+>CssHK?v-{uQDciN7(Mmj|YyF$&D9G)7%wL}2WM?b)oZrHMvX}Q1NAJDMu-AEYHp`Sm z^9GK(4Zk}?;~xt%9eO%-Wip?@+gUsh>=}5rPMrLi@zblR%Nh5sNX|ITU$z!>35K5L zfv%Ui@?I)`3ToIEnn|?E_-6F$W=pzq7WbD0~-0$ zm;CBv){FjN-1dn*^4GrjZ3dVTC&-M{nU z)>WQKji8HiGw-&2$yWO0%TRsegr~AX@r(B^Uk|;Vm%G;$d;+37*QQTTw#B+!wr8$L zULRj!#`I|SGRZ&SqdPz|s&k^VcQeXO?ct5s=n;F8B1qCK^UXa;a)_IFV;E$$fmixQ9 ze`nWQR9Pl*=4P+ft-ah<&zv%cX))s#k+Ua%GSs&1TQ=df>csbN4SpYEmzmI8 z)-`dvFP~TZvE7b&hcxK;Yzffns+rfj1V4Ctiq*F5=KCBR%dBu*@(B(XaN|#Ar znn4o;hbFS9+x+{g%;%H!LvvfnpW^xLH7WZ%TsDezy;{3T@IjiL!{lp+9((%QA3n}< z?C_+M?(e3m>Bfs4{NMQSvfs9r-HZ$jS_jsLeBD2t|MtIUwXedbrP=w-40H&aTXQCL z(WkA9&6z9_m!jXusc!t- zBTKF_%ZH!%$l%6!Loa`wmJ}nC&AhIdc;TN9zirO1xivB5QODHF_SK3m>OXh8wIqY~ zq4e!B+{!9)DLXp*-A@Ls3Fl8rUO!u`u>K2pGpE5U^Vu3b4fec#PcP^1defKM7k7)x zzpnVnMTaWhhTKhJX$5>-j>-$tGfI6MdMg*3Foi9dB=@=P{h9wu7f)B@t4#Wn*t}id z=0$+(r^v=WrVX|8Y?hib?P3<~&r~v3xgcF;@%`C;t4XdhiyGFpJ=EQiY|ycq&z?N!dzipV%s}4z$ZaFC_1r)bu!q2Hu96%Jba$dyif8o}jh>wDLZz zwARby?;0*X_6@5u`NTgn+Qin29&qNmqvTn2xbOMBUlWuimvFw=QMgvSJmy#YrjIWr zq7U9>04?*9PJ69UQnk5+1Lv)+pNQ{1v-72@yT0eF6WU+5 z<+iW6T-I2Zwjcs|6`H%AvcuQqLDjznK`Yr)ruLnZWH{!^{`vR>LoJ`ZE4lV9&yMn| zy3L;#{qjYs@y8?P{eNbx_!q#MZ)V5uIPZ1=Y^%n$hIf0a9?IR8HEI=qQM7S`T~WsA zQ@7k*KX@|UTYq4G!s%(NbUO}kL@b$5ExcsE=!56C>wiz%f9hENKTppH%hWIHZq2%* z*i}AT#vQb0Y$tfnnCb5thDN`M#_OJo^GdE?@c+V+ZVSf ze%{Yt4|kV$UJ3mW`eox&`+r*lY(e{G4(vNMuX3>w)4J%(H$N_Aex?`k{zZwvc~DGw zZ;LM4pinCxv?rYPJ?jUvi2D0mQ&~=AIlO285~uOb*JNjUJ9l2yx$8fb-$m91-Tv3P z#J)9Y{~pUfH5>SjBd>>hnAcD`>47b1y|S|9QqbP1^eaoYSo~Koa%*|{;-0pWF=&Kr zzGcy;^=mY2)ySy;NM%a6_A%&SU{BHfm3&|-7$sk1k`E%_dPn@6hVOyBcu`sGDh z9tAd_$%%#9)Aol7)oP363m$A_xX1NCm!a+X{npekfr*L@$3-e)7W6Bwv<-WG>%)(- z+j5^|%9n<;Usad=JWs?(-tt*Q4ku`R`egI`8EgCJ@F#!{6Esc<|Kl2U>AUCN+Gb_V zr3;U+HB@#!y`gp0J;b1XPxMD|)_wLXN-tdfeP-uV)7k64A2D~^$33Y|_P}$)17%kZ zJy(RS#Wi8#+3L1g`oa3xwHAMRpWoef;b5kM;Kq_aiOMAxF4$gPQapXuDLJm2vWs?e zY*@XDhp|MOp?Sl_1G$U_GEE{5+Z(s=2xx4G3Ax7kfr~|d>8-6E4dE(B56N1a?NPc@ z8dMqP|Nl**)~$7t`*;i>7nBx*cVWGqc$6bxVsTb_OnH|w=hB5tYz>h!3;!9W^l*j0 zk7C#_@?!2RHT&w*spS&(_LZQeEaCi@zB_&O;OJv2`M+rDk^N$aK)aI6)sz)Filv(c z70!E3s!Kc1omYS6Vx~fi0KP%f7_P5OA08 zfS%Sa_eG!582&FhaGQ0-&J`;Ewgw1+(y7AsxZN%ZEIO?xR_xtZtgU~gML>OxP3D=E zTxSl6<;NDrUDi=KT^%BD-K6uB=k#BQZZ=%H%E4TNn6j4B8HRyZrnFh*|d6tmEXVe$lu=Bug9w1wm-XYOS`(eZ0U3fTd{Rc zQ!X(WME|?F`0w6{yKVTTro@RKy!FBSr~ahE^{pRz8N?6H)))8|a(uhkAyX!iq941a zUAwPi^YMeZrN&%=prUE>O@0|`X6V z3$LA4?=HTr{kz@p^Nq(cdqO}p`inNV1JCx%jJK2!Kc83d_(IZd#-wO&H@61IxbIPK zS-zKbtOr{_ILTIa?nh!Yw@(+AZdoXJ4~2*E`d&zzq)Ki!oP~Q?9!G; z^&p${UryE9DE7#Z=}mG9vq`b;n>82W_Y@ooJZ`NyTOdek%RRFfTVEdk_2~84~e&pZ`lgcSy+~ahdj9=uHL^Z5wvt%c8U*2=akgo{<`#8Wp}wBINR3C zEr#CZde)o?be*upXWMxXPrGjsiwZvR^4sNO-cNZN@-kR*&dvTgmHEP};~X8H`E^^) z&(FA)(7dN?%dY#p0intVGVC3KuQf3o&v|iR{`~nhR}Nk~t=i&p{L{Vad=K(bnl8t&tiQ$YH_S9jJC(^`5uMPdClI*jW=WoL=>|UF?d~w>$eC+TOTl1c~3`@`x4&)H`)TK*tQ)>RJ)2naTo`_F>w&Tk!!84%;VGJWt!9e1J7?edbSPE_u-- zrfVfwE6eHP3mfU-DbJH>Uqp1*_pb)ip|0NoL$81NVaXwf8#1LSVS(qR@zbd z{Mxy^28-)W>7a=U$8!nKnBT0KU@h(^SJ^df%hKKnt2TkQ#Ocqs`?Wk^=32gY;b-p^ zpDklPQt7)nI`GeT^S^(V34J#&yV_n>zu3cnQ&7Z_DWBR_Ugu%(zFKVOaBa`SGwdIV z^I-d4>>L=Xo-dd9BM)9I1lpS6oAh%_^1byUO8zte?q7>=w2c7`VALt z=cTW;yi%&Viv7gQx91}?wVb?mG9UO~Hre17)1NO2Un`zM#(|wzUlp6Mv;55GD1%ed zkMkJbXDfF6WN=rD_`kh%vU}|@$Icc|)46I>&aWArT0i2ye0(U+@i6-8n_R}bm9YZ1 z^BNe|35tCIkIvlUGuw6CxCV3+xbD;^vs=46X9T@ts915FufbrB8oM>qqCy!}qrJ}# zy7_HP-+PC5g0g-&i$vDfebNq$bAR6|b6)mIldvT>Sn>W3Wue;}bs_3eU20q?tPuRJ~>S zeortVe0}$WN$1!F9Mp35*Zh4SZ;|*VT4ss|$Hp_+!N+?%x4dR#kY^OQXzBzyxHC>1 zbW!f}8V2!$l1}bR=E}xz336$fa*yG~vH$F^i?*|-7&9FLC4j-u;y?@q=1vX9{te|oIKU*`8$M9X0-tctFe}#L;9~nbtB+hMP_#WTj zc27*fDDd^yQ#E4mJePLyJwChaj+?`?aK397b;a&jY`Kxwu;cn61~-Ry$YoQ*#(O+D zix12ZSCcP2{CV>2HMUMY0zq@0Uhb>Q*N%F}Zm=$K!T0ZmhpTPQe`fsij%~#%!3*bC zSMJ+ycq;nEonu)nFBs>}?GO1BcnEaND(B;~$IfsVcxBsPDGHsvsHs+2UoK}$>Y1f+ zzZbG@ZQCo|;MTBuwt%wqfnQMqFJ`d3mVemb;Phv4gU)QLw4|;l=3H;&zniYz|G=P( z`2t7AH}=5sTiVb1@4nEFIb6;(pYktVFu_k8$>)uc~ zk%+sBjnbd1q(x?M6(owYoxkz>(6_C9r<7a19R9R2na@M@Z@z=wo~Q30Rm&T9fG=0& zU=P#U|*v3z>*?dj2iGMrQIFkYA}QO^A1`s}%HteMVy-F3h}+F@VTqj+xS zEvzgHuDcfG>}I?(Cp!N#qY$f~$eQ{et)9yE?-rOidB|t%owvvA?Tf;dFPLj6%2oY|yYcLijhVc!c#KpJk0DY)(yGIal%mkK+p-1fa<4&=`C`@e4V3non1ZW-0he*A_Q!-M;4J9;`!ZD;t>{{O9e z4tIz5`pOL#r)^BwD%PCK7~R%(Un*eVHik;R&brd|+p3e9 zY-ixPXLaZk+ou)l1fS+|pAdVs!&Ko#@#$?13}2URHL2V@r}XaUYbefvFwURs^&+TgK(@j`=vo=vD3)0x{$JkGjVXT{HKYxwu+mN8SzgKy2HQ*Sfv z>TP)TI&^{V!C&n0b84=5u%`+Jae@|b#m}|-rQ><&H-pTik1Kox3-|W#G-ir9u#F+! z&0$7WEKB;n zExHHxoq9KC-Yq7U>t$OLPD!8Nox8Ak6NjeiX+Nu9$+J$CGs@ifYy9~;>*C#vvo)D_ zxNXo?0A0ecNmqeo@7Ad6bwT?-W@a}rflf{TW4$ZwU|N(c?*z^Rg=hZAIw|no#fF(ET82E31ZG`0E3Y_2(I^`scAXcS7?e4o%)}f6KJ~ z@a3|9lYO6tzxeo2{>0~ZsaI5H*e=}~ZnpeOE+gX`jsn3m+ZrrxGj(m*vSjYo!)rJ? z8-k=5?pEB_N)jbZYhETK0s>$@Ku%Vqpo{rpiJ_qSV2O*;al+QathE_ms7z(B+A ze%;Ss?P7j5uP*bQQc}qRZ9Pv7?w=R&`qFWRpGs0SjL(x6m)E>c{wT?)*Q9&f`q^`Z z7obl0f!1GpqC!C@PfXEe;Qw}!N#Of8iRz1qFSgE{47zAk*X@})8_UY8eg?Oh%+kJY z`CGnEW$yhupPQ`b7YB(c3U16fbx}TeqBnyK%Yl!-oAz&OsHxI@F6#chLT`%{+h*Mb zf%lTWTYOy4YAq`9DmHrc+Pjgjwy)p#=@Db_D_I8H&WobOTa^!Jto<8sI&`D1f(z)x z)`r{L82-OczR4=l!?|VUR}bIlf8QQ)E`9}GK)7s4%aop@m)+j}d^Kfp{TikHZr|JM zK~`V9>+o&LH|O5fr^F<->)gn zn>aXy)^6vmDt+~0a@MU+7B2g4)GNr$I_l=zwC&Q&HmemG!6{FB8)~XzSvKn~h|E6d z9NqAsKwDqJLN%B1$Cflr{eN!_%N8BJkZrtv28;F8*U#@fTd(qOtAg4=->av$fmZ;0 zT^PQgyliRFr+eaezb%-&iQ{9>wbv&%h4RZSU$mLwjr6tOO;35B+`E7LQB@MdZOA=( zENlB0Zf9L#bGPW)p1(PBrw4#`L~Zqd{p?iC-l;r368UCNO_(g?6Bb-}b%tHzt*g=Y zI4MToYqPitl6hm*-Nu#(MtS&6u)uf2pn9 z{{Qf;PRsg|mN$IXTXmRlo1M!3@QHbTvv1(6yBr$lqcp-g zH{N@2&B=cKlQ-|ztho2+;B4o&TIDOR|MTt0JC*%izM|oROXD`bD}7aopH^r`XKj_- zJ-`05_BC(iC7ye%QoqxRAf$C`#i3w>wt z3p@cG`gOZ1mwmVO_PA%vJVFUE;o;YV_5JL!zJW@5YmHukbRFxDGt718R-JjO^$&yv{Q2}x z-^PUL(~DBa&*yXh8O#5V;<(RIAh_oy%ZBAv`?e;0*uzj=wVk!(R~w(rt`9AN@mGHx zKehXE-;}25%X{v9y}EL-zNc_4(}ltlh4~Waem6bUuWqW4?>^75LH5ln?LD8?up(~~ z+;_aMf#1SH)-mv&B*V;A&uSRvSN#QDoY&#LJ{Nq&Ox*0KyBsG3eyw3BzLvlJ(Wly1 zG&)Xukf&pn6#LGbFGakCdbozI_8(jVUWTYB}Y>)UnW zcg_Xf3wQD52R~EZhPA8or-Nn$64qTW1Mf=Jb@TD(HaI5@K6&G78jDWTi`GqC#ozZl z`exd9N;$=0+T4mWvv*}xF^R;pZt&gnKKWZ8hx_Wcd;ZNAOPFJ>rmXOy*jsr4$Er__ zj_GHAFJ654!Q7y23^VedYhDMPus7q$+3R8gr=v1tPJwP+xy5RF?Z=kDub^p_z0IFEUQTya@4Z?6@=CJad#^8?8fpwT*fcJiuDvLj!`B0`Ilf)YY@PvsRe9s1FOHM^Dn7OZiZ8eN@o2Ap zPW{!Q%9cIn7QI~j-!jnFb=NMt)ecGjy)M`%?$zgsNd#RC_tuPYlfB`A*QRG*?L50b z%1B=HOVo?UYuFcE|2z3O*UI|vdCf@+r|2@&-(AG2aKCJF(N@+OuS+jT37oyA3_d#> ze1!JW>&wFOa~T;n2q{fhUm3IO_?+l@r2%p)HM*Dg{xW~*<=60l zt^dsUXMXfH)*~Q&+gMZF|KF)f`&}(F_03!_35K0rx+?`A++{J~Std9^S^qh+$Q5s8 z1-Z3lA9GliFx0W0I9eT7@w%pP^Y2Mh=A^%hmolpPx+ytyvM}qfKD%bGES4R=BE;|i zp5d4hQgyaz>4E&imuAU>wi~3`6{Mb?c|AY;T6W=5o`x9LbQ7jW|B^R|%eKYMt~{!8 zW97XWdXxTrdA59u8$&tslMAn}axgTgEBv}Ied*Ry@NlSm>Lj6!8Luw>?mQJP{34w3 z&5V}{FTdBD@NIR8c+YLZRP*X-UV~Cr*ORq&KA?%Rg?}GJ{oGlF{?i$!o^zelJ}t zv^OY{S>ZaTMrL%w&U;$%Dw!-6*QKBA)IFKkaHnc@HVexJX$A(73Gpjle7AO6v!izY zue+~hrg$kOo_wPG-O2m({?$n`4S#=igU)nY|Fn7z`q@lTdE5aPKQ^arjeoSlWc`$H znhcRA&7WSrdM1#)%9zVw_K#V*yKIC*es^4YGJt>t2)Y| z_{%NMcYlvCRIi$C{P+5_u)8IEZ~ie$uVZEW5)de3Rq?aqslJchD>KHrJb%JLIoBHu-sNx7?)*LLn!=?InT&N` zXR+?Cx;Fi``nMZLU+7z}+8XVWf1h0~^HbRAr!3A-c^mRR#a8^W0bL>>FP+hIaq0dq z{avT)+qmQo(yR;*2#PmwGOqS0%V(mFuo`x85GIp0_Q= z=;N7Vy8r6`MsV52h3zflX?R#ER#vFI;J>z`<@@|i@}l>DM_k+UP=fKSdH2s72iDd} zb;NzoPV|m;;NKAAW)QNEb%Fj3u?g<$7vD2{f4iP{k=EwWQ*o*v*S99^Enc@p>UnB( z!^T?&w*A)<**}@ZK5o9+TNxP%bMIxnb_Ss}jv@Ow67py7ya`@o9wmQvi`A~<#mlaz zWmx1g{=E9+Z1mS{47D4+p7~Z^7Sa};!C=zh){vQD>D%z85}G1JCqH2La*MO?Bu7Dc z-rYaU;#Y37KIj+NYy0-f>~H%j{t5S;QWvYt{B@#v(z>YG4DL)8OMX93+^2tmqr-Xi z+dVhSm|GTK*SWD0TW%{gy~7>6*Xly(X*-nz9tx7oFXDP1 z9j=ZqC{^9Cxj8^rgdJy#y7s$AE6=s*_OyP9Kf9Y>y_KFbhwGBMesBJ5)y%g) zwsgp_zDQ@t;Z|ta8vUfXbZR+siZ%E!{WQc~BA{87b?0)kZ*5&Zr}nZppGr08Cj0n( z-!ge$pIvq;moe>PVeWs^4leLe-uG?bU8=mFl`PsiPd53f;$qMFAf7>`md*YDzJOHa126Iv81`=43X9^Oy$z?{ zvrYIC+njdyM}zXtdm3vQAMA3FVl14=@T@8qe5B>Iqz$?Wrvv=m90Z=5NhvR|elNwa zKm6eP1)pwT-zNQK{~g7IW1y3s)NVXuzVV2;{?GelHPA7k7qYDLD{nJxYPB+BYO>q@ z)Qo9T2!Foi?>5%Hl&ZyF`We6c*O)r*;52#71r_1se+F_@}9^&pVYJ-g0=Wfye=M*N>hK8xMc_sx9Vc_p0BaYJWyi zywcq7p97m`L*|9^Gi@z=CzU&Xi4E8z-Br%C!Z9eNM*PMsgKJKVd-c|_FnedbGu8ck zQlpG-Q55$>8#9CAxYgN;_qXb;1Kl_C`-ks^s?nm9ID0_VIchxz;205-13$?oR zu52jyv_h0Yj_Hft#3yzcRrlZBmi@Hi%IlS>l8y@>AKSXm=FQUWDwAXu9Z+PFVLGU& zX^`@neg3|4Dic1&Dx7|3=5+Rz*~y#QH?B@SaLKZuE^l*f43GQzvwLR$lQY`J3L55{ zpz}V^aeb@ZxhjP!Zpjv{!#dtvO{XLn?zWUZ%v^Kqzx2+?0t2^(`-Uokc*BrEzV0;kI_%r-!@A^NNKK%~Y zon!OLlfj?qm&&9knu3*^v_0qDdA(H8bW()ZciJ*c|oEb4zNyM^yvE@1+O+ zF}?ZECAQbnG3fR4ITG{F7VO)2Zt*=8kUyrzc~|oGb^M=oE&jo>6G8EyX{2+v&aN~6 z9A9?ieR5MtN2;Y`fF&~n$J?{V@}nCj#;Mouxw#lTkE9{Rxbw@e+)TsEx2*F|-eT(c zT9w6Zz*AN%EEF5c8=k4L40mOf@|iZ+H<&Yd znC0Dn|Mz`z0-=b}B{Gq^ddyw23yaHf~>V%%jZ@g>sDQ7ku@rT^zYUbxHh(N|@cxcxq_ zUfUk~RaJ9-&&o8rSh;IXmOTwM*#}_>xc40!(*^LsWL!Vy@E_O)Zu-1^9B(`=d)A8t zcKx?=msE5excDYf(z!R@uJ~Tu!CAH@9DBt+OSwqTWo}NWe_j?mWpi4u{oG4>TmehWGpAz5~lwA+KVEghI?-$Vt+N4mUOe4 z>uLX=*h8STTGEVezZv(eXI;E!rr(Ti z(F6@e_ktd`2JTPwGmL(nyD;lo!L_D~cO4iYN9UY<#}S+HwK#8e4$GI%S&nz_zp!H0 ze>nFOALE%Or3;CYy-n^ja?=CmQAK~?J$SBSXdMix#n z=cl5szq**ZU9(nSSgFnEuzQlIb5>?}cKD{c^V)gwm*ZdU_p>ay@ojZ9dv1nj^cG{u zeSuT@RxI4Mw(H#_t}@}PvB3w=acT1GZtgtYFQ2{6{X5t7N>^@= z<&||s$$RClGbI?PZ79h;c$KSQsd@Unwn(GB+$pwq7G7(r+|Kpx{|ke!QKzyCTzlc*-`dZjPq=GU<~M1-fw}f2G>ob7%mE&liumQx2~6Azxcd2Q>PZ|=2!(MzP!uu zfK_8&+l$LhPck{?wUzyQ+ZucA|KIzs%FCnbG8qoo&;S4VDnnTQule1+eSb^;ChJz~ zN_{&P#r-Yx=sv9*=7DckAJ`Wl!;z4o@Vx9nI9IlA1n<2+`x3sFsT6NhIJY+Ml(+JM zsrL-*eI)mCr&!)`%Q(p8)AZZE`Dr}&wD%tuMRD)av@lXWa4YJ>_j7{RP1o+`Rf+nR&8<@-4QSHeW@1z z)&-AG{@wTcvv^g6P~e7>Q!1)1|Nm;|pPRci{d?5T@>`*|?R}rhu6QW0VxM=z_675= zt(53(_#iL3JvDM(Idck6SvJd#%A5t#d%wv`-`LA_s?zuLJk^}Nte?OAV$Z8d`FWkG zmqA$hz-v?8V|N*@eR;NcqhP?Bjg8m7EZcOi=%yoR#AJ4X%-tNziKhAM9~{g2vwz}j z*5es0KX!hw$mM>)^1-6vcSynfB`<4Vw=Lt(`E_mY6_4s_@rHEszxQMB+SfmMoqL-v zGxodR)h}-YEIBSXGwfkMu;AT{XSFk=4sdXMS`?=P?ohFrIUKpZ<(Ssv*xhAUrzZc- zpBA+Gj@X6TkY&3Y9lqRJvBG@`lfdck6>U+*pgRP7o^5OR*UbBAR#q~%gNwvnt?#uJ z#~y6Bm-A;E>x|cD#j>thPrqxV5w4$FsO->a%Jhft!tP+-cXFH3zW)6kQ(3Ng|Hr4I z3+1=N8GpQ8_Me$SN5;J9#jb-d6}kS*X8O-bzLW zlU}rczk+dLTzgxq7{%eVE*iNi7w|1NUFXv!3;~&S+_hK*B?=F+Ad++%5 z8}GE#>GOP@x#~B+{C}?EnYX<3CDWuE>>(cwb4*z7soR!u+&GXL?QrZuQx!Xh**?yV z)z(7S+cl*a*WF^;6|d;Cc}G(&&^e(f60BkS7ujP!7s-ZbuvosOcpn;W$!d$I&|yb zY}GBNav9rt86972{h8=x{&%^>Owm((M2B2eH z=e#;Qx4c)Qj@9w^0>|e3+v(?Y*IQOa22FUbUSD;*uWy?F5+!BUDwBqXuEm!)UPwm8 z3MVATNijOU_?p8c@w~TAcgkUog0CCbfjdGDKYbgHRo<0klwErx`F0iPYTZw6uDlJ$ zmQH`pzbJ4+_jaN1DBb6}%LO+GHAznrdF#ntcXaLFk01X$*_LZ%b*;vW^*ECRZ$iez z8be9gVabIrxBfkN%QxC#U9p4m0VmeT=F^ExPp*3_A6Opuj!k2cT7H}%=Q*iad|9zJ z5oUH~yFx;4Fi89mX?gr2>fG#)N|MXsx+@=>GX-hR`C3-YWhwTfi_IYy$8KE-I&ld+L6DnWTGIdA zXZ_g#DMqs`TpKbjM84f^J?Z^aE*?f1j>U$;y_ZuszS&!(o_=ub(BaG3hU*_LaJZd) z&?4WwX}{yrSXTR*-{tRX&V4BmvICuqE9PL`@J;=g{I@oj>|}*ssSN*fW@&$BKHMbt zD_F(Q=d zl0B8@EAvD(?q2(!m%*@yPhR@PX^s!|!jp7weU7S-oRB>^>U#e@tMBRcXJe!~=P^Ii zt6)6f%Ai-3_UPez@5@iRnQZ+U=WcqY@i&>d_iGhs$Bu!#^bFe5;>tT>$FbsU-6glrWo7Bsx zIqpBpOE$Alf3mUhyJO+e30t%4_q_aAwB*p6;)`>?GwgWY`LTU&#N`wbmdT<&9Iic1 zHetF`JEJSzyuo&=E$G7EZ)+FGJXe@-m*L?n-5KSp>mU64fAsX%6+3F&r@iK5S8Ii@R;`acOEyxub2gaxj)ay$s|WN*iN0jS?UeD z#zWBMM4NjW3f|>Qb+11bb>iFJLJLUKrg6<@#zz(Mr=_A~l@&h6e>?F1K>cebKx>9#S5=?C%DWldRB5oaz`#ll|`5>b`gwyPsY;?E?fbrK<$z4>SCPT1(_5gP(89PRN)H8_xIdoQ`66h9obo`tkk#lK^E(dOS6shkMTUr z+BzY8lf3M*IPXehIl;cL+m)N&vT?YtkKC|&_xBy^GTHXO)Q)%(?0jy|KRx$PdxAKb z55#RWVVbqpyr=l}HI9H6>xCao+O8{Cw|dL=@|p9LovOeafF>+meVij@k7qSQZv%r5 z!{G!eM$1oCW=x0TKx+V1s#lb+75gkDBRVZ^b|w5!X!kf}h1|Edsx$sB6PG#p$!jSO zgSBgPQJ3w1$0H@+!OFPZZVgO3oP{kvFYtc9XZeMbic93@&9eC9959#fs`7+F#@z-? zk80yrY!ZC%Y-8iPFSo91o!heO;%w#hpgWAjPMp5Wa13;4G|FWxQ~x!E^VzMgwZ3`B zK0wRMnt6es9_XG>$y|L^(3L590yCL*1W&lazvoTgZ=LySPE{N4yKet4b|8PgT>TUK zc{375)E;hU>)u z?ob|)H?P(nnR+j0VVd~KH`5L-JHadPhb4CF&HVi}b(zb%CK=o;fA?oUlf}Q2obGef zCrn;wCC1y+ATeKTf@Qvfn}bc-#)C5sTw2Q-u6i$a9FU0%^?^xnZs74{qgmSH2$(J(g}N= z*8A^%63rgJ@qs4$@}>!A-qe4T-#eb7(v_)vccP<655J-oNv6{tLf7+jRE&x~Ki!;65aSefGhlStrHs{MowUqq&yG zSG~JM=NNvx5;JC)b*=o+PlmJCqI}jL-4pG;UVQ5NveIpHsy}?skAAYE#EI+Ulk5Gr zqNA+*8c&5YY~d4`zth;_M%?~K2j}o_FkH>O_v0GYbL<`N>wO{9L8mK??aT}~*0vp- z!+)XtUdYpC=kuBRZx`=aoyhQH#kE#Jf#34Z+Y-X_Ro8uqI>+tFxN{?S(IsoW-H$Wf zmkK3KUB0jIM|8ks%V2QKNYxS@@@BvZ}yBojs!pM|FOj3 zT=0wiU&N&!*D#dtof29X)$s5$Q`3d)3FQa23S6iGC94Y|ds#nAon!y7ON^%}_H4cW z`=}M4Y8p=2=sxJ57R=`UD7wL1WTyPP1c9uQErD^)d-YB%-@5Tb(sQ%LEZqmATkQBA z+}Ixf?eh1JhwU|mf1lm^cwKE-)%V@5g=;jVwr$FoZ+~&kLAJ{7$Ny>%zO#;+eDC<0 zl~=F3v!;n8+FuFS==h-Fpk7$R!WNl>Wnc2hGM}e^#ruZJog7b^G^yUrpWy1N#W`eI61^H`em42{8-+H7=%CNpCU<{rO9K z_gd>03r^SF(tG#LpYvnemDex#O}}&Tp6bint_Gq9y8b2o{&zWW@`FVS-7aw4kjau~ zmRd+e(^&4PP2UYCU-;0&AnmdIPO@g`hABmC2WoB8 z7+P*a(sTC1JIfgM#d}-%S$?<_uUD!taY906*`?**UwB2IYkqY9pkpK3!a4i`)8l3z z%zMtHj}iX#C}ADERI7>)j^K@b#Xe z%&h6JWw}>g_gDDN`QhyL_-zZm)H=3&IXe6KpEs%u@ikR{1j9AH2szxl7{4YkyS}C{_ox$!R=N#cmD01?-esY zyKNQQRn9ym->%_l{nZ+G(?s1BujaP3yLf6wGyGDTvo`2vpBZR4#YvQz)%~K_hgHk0 zUQSfKy{}*XA1C9txz)$>|82ZH%Q@tODWlZ8mY2JA9X7K`Khfm_UqBv^Z6Ld#CA%?~ zvGvO>e$#ad@hd~~xE+=$AA+1i4_n*d_L^1I53v;*kCKfMeth|t)#Na$Lmod7nDvOun5$J?m&;`_+K&xV= zeM+-Yl3Ba$^R(@{3ehVbUt`$)yzK1+$Jgc4e!a~u>g?+{CB<;;=w*%#Ax9k!I-X(w zG1*~%<%REcW$R}fJA3S`cs09yzqHbHri%9t8%>y)44`+{oc_t+1`fsbDfXNGWWz3| z$$2Tdvv~IATm2_g&5X1zGhGm0m}CCZ+S#Ydca?;O#qDQG;!&az%o8W`-TM3Y{qy#E zr%jvhtCs((yg&EeXGYN)-UF}PHo8eS?e*VMZoG3AXl)~}y!4CM^=BX4TYaGQTEY3X zt=k$ht~({WK8@#on!|l!+Vy(S(ezx~SUp^~{m);uwI<~*;|A5lU43_WFR1Lb_r7$l zkUh5Wy1mR?CMT_f!jJiH>pOmmXZV-9AmSa{4Wli)K$pcSsO<&a#@V@g=~55S1)=Z@ z%4A;mF?eSE=C>@q7rgU`tJ~6YhMdms{HmVM-zv9U)L6R))=>NVYc%eLV^zYMrD!}VgT zeAbkuf56SkW%rIP)SI&7ZfmOe2?NmPC`a#W8x0pYn(NIHqAk`iPG;qB+2XQ6a?vJ- zh7*#^W|lWyCN92oX3x6&@|P~3@z4(`4d1(NmEHZnYkHpD-S<9Z<+IZ7m8JRTe^@sp zu9qpkz@fT><3LvLqv?NT^FBo9eo##MQn|SE_xYIJ^Y=ZRt1R|+_09{?FN|+&ZCb6? z@PhAj#Oktp)7G;t_Gje(a_!vgTwRm>$2zNZP2?BGSLHs7&0*@8*RWhf%#mS zzb8*@IPj3KLG8y&E3X;Y5Xt!NE2;Xe0Hg!{rxTZesTX4gAK9Y6Nm|7}k8>z`Z; z@~(AWzb7Db#oRXX>n(lm5AWxM*{^n-eSqV7Q+g_++#3!9t2=cWd|%ZL7EWcI@$#xZ zx51@s`zsY8RgHCT*f`eeDD02hncaH-@5|E1=KM1>${BVP9ecC$_qHw9wrzP->hYUR zx~j{omgnHa_?;QgN~b*)b~5>K$kNdlm3CDE2<;EoZ(`sp-B#aP3XO z1plq`pKqzMy!yy;!*70>V^IZL)tCIY{;^x^SGwl$ufCrb@4fgsNB4#1#dFeEqE^ge zsOn|vzMd_%ru=K3-7K5=$%_g~zs=;DoKenj@*e#lq{)wom!{y+vf*)#kel|6ZM) zx&2vR!>@&UAJX%5>+@xu5%+bvO#;V z-ic*fp9R`-Sv=jzA9kmBc42UQp~`fDq!ZK6&HwW%d-eIhRxW;w0T(Tol|?pO;x#)l zTYegA%8Bd+*KTlZkk49iLt#QHqg&*kYth#E+a4HAXLWeZvH#00iNN_DsUmM(vX7)P z28YQOe?I;*T~lX~4r4@BvuDTc@uuNcd+}E_5v*IE8n2Gqed@2x@~h`}Y3QG3`g47@;d)20 z{ih|0&Nz0YGR`{qtV(w}s|1_)elaoaj%USZR7`Gnl=KBPoz6WMsNu4gSz&tcoS(68 zSZ-@_8~i$_Go53;Oa`lIVZ!wTZ!16hcl^((x_8BTW5L64hM3jWJ{_rydF6>WL*G8P zTpJf>XLez0ocO`AEp7Xy4}?ZGh*!OB&(iKJ*ZmPJ7I5a=qnyQoU*jtZTN~qNW|o&7 zI&k!I0oU`3^6#P_-;a{|b5_o+lDFye%c(r;MOK;@pYvCE^&(eq*VdA=HXNOe)7|9$ z#50vL&NAVMk-GS!^PIQjp`OD3Gqb}hJ)U#CJtSHEy_=f^oG${D^a-Onnz zNc)*M<@@^zoQ-0$$_Fy5FLMaB`>n+RO8vX_- zhc2u&_;z0V@$M_X@5@-#UCZXX*C%kv$!urBEpE+Awk$>SP8NRcp0}m>bDNalUcm(W zD+=nTnGRjNZGWY2mrP}I9CxZ2)0e&vyO*dQmvM;`dvJ)O;ZUm4vd_ir!GbIk%@|{R zN^L#%iW{3vFq{%l^H^jrk6UH*s_l9A&byrazFpFO@#Uf{Cy&M)v$9`K42$C|pDl2o z@*s&p`-R-@H_v^W{T1x(j(__G`&5u_Tq8} zJ;Azlt-mI}4O%bX`+Ziw!M&{kri@J4A{n{55^JYI5`#<#29mWkN3*WM3%G}bLo4j*%hY)CA4w^KBxxiaf|uKvt#PWM7u1oq#ocr!&kc*$+&Cv7^%H|sRG zIhbnyW^UP2_IB0hZ@<*u%WTj&@PlJ|RqTOH4S(Dk6l|{^ENE6bx18Zm!u@C79$(@) z7XMzayEf^=RqCcQjr7=Et`qt_J1IA>WyAhzlMw&;0wnWygX0TLs)B9rnpGuDzaZZBZTBFqikqvV%w0 zvpzlB#~^-??f)vS6@QK$ywIfV#eCqLe*VnUOab3tIDO$t3*9y8?e?7&$C!7vxo~jK z>pkYTZ}sxnGM0k+Ycj_```K=9&{1%XQ{MaT7xRt-?^w4)*)T6$7rXlvw46KE^(dbE z=rmT5-C|I&F$t@`|RO{{?Sb z*>mcTe)(?1-z*>`8gEy9YBqORd}4mbE+v1tr5`7&+JAe%)bG}?S~}bOFXN#|ZoRMu z@4b?rq*64WMSDfR@@w7H{}&%_Wn9pdsyVk{I);;j@6f{*jp^R%ecX$Oy=$!zl{fP z|9&{(m2Xe(UZxs`*2FzO)mu!GLDAJ6BFpw*vrQ!|x(YIOGdgK=e|R*@(Rj9alyPWIc_W!jSy^IVSbPn{zc}E|6 z#lBC&>+R0BKW3JvP4nQ;6rCMoklQ8nhVQ}EJv+0evufN;Xx{TPG18%J4_j7mFT?MJ zZC$)fkzxr`RM$+JW@8?ls>j`MO|b6eR>MVh6E}Wi4=B3lwSWIZOZG|4OGDTkM5P$_ zF4PNodZB7|)Q*DZxySCGiVt$?5S%^F{A+&f%L8*ouEbUrU;S6|x!T;}3b=6FS`+1< zQDyurcgE72{ylu7dbw2zQS|X!GbNokA}`5& z(1|eF6>Gx9^IazJnOmbG>lh7gxSdP2KQ2|1bnWMb)uL>B1CrO?sx+Rf{6_2h z=Q&~auNT$Fon134`=G%UhqVV~GFbJ4ZZ6sy&1k@W;5EZDjwRI}yFX6}TFn%}%$4oD zfpL}%=aIg9shcIv=Qm~_)Oc^ioL$qq+?Ks9YU^jK|1lYM#n1%6)nL;9Ekn0twTf+* z`TwmC`5LTTb}Jv4eVlETx$41x@q1rf+iy8b$cf{iwle>{NJn4B4Q*%l)Z{$4wD8-M z_V2n<^3oZB?_(JAT377W61BN?ZQp~=t><@?=521iXZ2`;=%lHW)~Zf@D|%(0K#lML zudrta5{eE*@2+e*y+^rWH{+h`s|&78xSaQ+bMbb5IVlF;4aLcCXL2S;yg9&8v21Vb z+x=f#x9@wGq2jXK65F&WCGx*Ag!B z>&C1;x`)mE=hEwGarM0nf_rSr12(;#m272S{$<-iXJ&`yjB;iPw&+*<1^Lm@zdz(2 z^Zk3`;)l-VHnmscoL$&*`?_-0O-kW*$mMD+Wmva zl%AI^w-sMo|80u7EDt}!him?PKb@Fp6XEkBqSs*wec>RNAKegBVdB6MfGwrH*c3JyF`FvF!9MOBmvqKQJ}y?+W4%)nb;Dva0)(?Rs9i@N}`g?7l}0KW(ow8=Z0emr>4~ zaxFRz6wOj?WtyswCxq1Pem8US{c@8fsXtac-ViFbW%=>WNsk$I=2y#^sB0`KogJ^= z+o1GC%znGwTeb=J`7OWx5|-WTYT}Z~V6*22gT_DcMS%r=%Xch|Dt;DwWQVwYROOR$ zYin)3Hczf6JO9h>OG*v+J})3EUFC)(*KUT0y=7aJm)|&dYcBJ_!whm>3*~0rw|&qk zd)$BC^MY3%jF2QHx#e7H!>{w#<|?1cVVQEBC1T2Sw*x^7`L#XwZ+|y)^88|xmSuaa zt9~rYRGQ9kL;JgogpXfEwz+?Sj2pvgCZEq@IV>7BhABTe7^e43%PNHL4~z*mKv z@($V|j6aT+Jl*e9|h zbm@-V)!obN72l{yvvN(C-f#OMMB}ICg;VEa>w6jAu@x-bWS$*SmHYmX5W@>kmio8eZPwJ;br% zlOD@vk(6VH7@5`c%g>#am*$;Xaok&(-$rc0W=53;b~lFr@ruZ=mpC@)Dm85VFfr)U z-LL2NrA|{`IkWP4%fspwAy$lQl2heg32g(N&l36d*md~>KH0B-F>_q`=EiPRj2-@sR1w(grhg^?3_?8eTER z1{6t8h`Yb`R$gN8>4M6SGn(Bx5|qTsIrZEd7vRqWZ;y);MjGjzQ{r4*<>fpiG zbFE)ix_>JA{O?8c_1Br%yLq{yqh_<8)wwt4a?F#Zf(bP(kN>%ceatj(XgU*7xO2UX zO7gKKeQnaRON|*~_~-m(WMF)z9oU{z|1o#F^#os;Pd`pDUz51dd)RfQpwf?7Ot+?S z7JYM+TOhdM#R9#h1=Wj|6#rY$XkM7^P_f72(}eZhmu54r3HmG9+rU$KQ~p3e{u{T3 zs)$|Hd~xY-zI>PQ+;q%(aohT(x1N?UIIlh1=s4}ewY+%={k;s+SS>(ZNAW6F{qQRb z=7w2*m8ug=cF5#qm@XXf=`I7e&)Iz$qkCr8nOC#596o(qX2;ehpZ0{k`|Ov4;w3NSTN!RxSEj=^$d;jY* zvA^4m>T0$x-d=ue&SU0xNIkR;GOsWV|N)OKeeT>R2XhQIGy!R+1$Ko&|8wP{ zz#{cJW`)BZ-TzxNMBc3ARM_!+a_O#p8@IFfe^)$R^#6t6>Gc-xFTeLxT0Hg8GqvNt z3LkYI;%H#-Uvl8)tG$Z#QnyY%zw+zU>&5Om`!}TD+seSV_Nx^rUG?r?s`ELoL7}&y zi^FYQ@wFv|efJhE-BWufF`4C6_}clmjiebD$w%!7>#pSO19y+x+!{{gI>$|7Ub1=f zBaf;tQ;stj$P|9u&vUkEuMyKT^%Xh0->Pok|G(%iKcP>Tn z+xyuRN-#3qW4dx9?`oOhe1!+?T#?27N;l_8jt~Fmgz0ZNyaw)_zpja& zpY>-!^Fr}c*2nqpyDj}>@ym`^T}4^p$9YyMM%Nc#lk>9OwI6(C%(bmv^I>;%T)E0- zlPQ-OP8d|#*C~F9&slhHKmV_ar~1X#o8@hazcfmJ`?5sc>BD}8Ta5zO@{Y~<_=b&v zC6-~Gk5=yfWr7dn_2sKSGNi>iAN+lO$f)wK5fjUg)hkkd zSUtbK8PIWWPGu&GgzUrqu-lc>Z}NUZN)F& z^H!6>v=*5#$o=}aPwQ4Z$9W$H!9U+Y70}|v+m&Y7)E=>u{-W~MUhsj(l^@6Ue<;nC zh$&NcSn_;|?z#PMb)z#q>*6F$Yi66Q^xlt+KbuZ{yhznr@xl`|NJU> zdHt)58F3%LrRcscS{`>FPZ<&*tvY!7$ z>=rFZxX^TMLHOF$+qStyO}*FcR-CY+wD6vI{GW`ct7UvSFFtsw{O#t0mCE1u@0he@ zuS&r2Md!-?i>+Z@eK4)+>*Bu`ZW~;5(2!Yp>6`d`+p7P@@xLx^FwI?}`t#@YQ4Sj%$C{nN}=sI-mKj{`{MBo4(KIKK&&0+?Q7h2WGzBXZYg5jr@O%54h9) zD}!0WuhndPntkv^@BwDnK*O2y8_!G@Y>1m(8Cu@;#d^=r?cPhQ8NLP9wu`UVzNT~I zZ68D9uan=V-zl56I8%YQqVNf$_SyGd{wxMu2Z|Ve{b7zS>*D?JxLkMEKL&;mk}S*l zBrTr2`Fj4{oP2ZBlZT?+Wi}o9^YoJT)I%L<8}IN=Py`==kb5_efpOQuCo4+N?|d4> z?)-7uc6WKB5S5?WI(58nliVFVUYqTYo5m`#aC^f}->E_Ww|;tQ9{cviT5sVBwf^B-NBe*SnhaU>)-sa>1txJm z2+6;kvR-lO{CvZ_TU&)beBPSu$oT2giROCV6_Xcy^!HU(aE?pf`_z}hEpOU(in z<$V>`mRQU39?X6wQ~pAh;ai^6iA|6Jjl-Ze^w~Y48bAMeEx&)jD4E?QifMyMx7nqHa4ayBwSFqjB*XV$bK1vyQ&?RJ{Z@;g+QiUaCGQ`y zDLFF#A0tCk_LfHxVhM>XY6sWqavPj`y+>!kPt&*s*$cfz{~Z^Rx#p&Z4|mSzi{F?VSJm3_tM8qm4)=)(=9m64%$vqq;&|cX z(ambT#VYz1_vC6{Gz+{5`n&u7bIxz<9PaCv?wRHOWARqC`PzZ)>xy3amf!Gne-*e% zmZ6q&yUu}oeGNU8tp1;$-PiU1TX2K1`FZU(bB#G2Qs)01u5C@IJUxT)==(jL+2#xl zqAmrm9(*(X|LITQ@AsMdx`wIUm(^>xz5E&M=Fv6Zap9t3p3_W5PtNXvo*i_W$*8kJ zc+tLdv;Qv@czL~b&yV6+b<-TqWlsCTwgGgq$~K|?>xYg{T9LTtXFjj08N;fUY?ded z>uOitu$lj>yIE0?!T-;T2Vdve6#e&`dcwpZcTKU+HsStzo`=lp{>*btXW{WxEVnPd z!Z_Ww`ipt^_UY4RC^X!9(zJ4UXF@&0``-n99A`nh=r=X25?ml#bG<>ty5{4Wll)c_ zgiMqwN?y+YxmWCw2KR}&V-~4@ z^zksqh8B(w`vaKgSASda_kXl&)ICwA**$ZuyeFlwbG-Zdlqu@>+(nkluX5{d7QAqU z&4ckz-QG~UyXk9-)eGC>d8?+_guOT2f88m&mqFZRuk?YPf)DoIYrlPK!{=4?JAQIL zemL>W1%`+=gL39AmpBBL&MPKmVOSr&rfj@i!EUYzUecbMsnL>hc>q<4#x3 zb=h_4))ak)qNBy`vIdXWyYoihkBdq=r1spe?(NsB&)@MrFlW%JyBG0$ue3rID1sc% z&2l?%TWQmM@$)lF7WK@$`}jfb;)J^IP4x>-+IQbKzjkXr(@vKkKX(SCDLcIHaN5TZ z`s&uyUAMkT{i!$KmusRjNm`1*^yOXN2gmi-H}CQ7ZK$eeRtYSYIiWAhH9@^kri^9M zsmBauo99?tC?5b1^yr*>yP<5$-?umAvkLtJo_Wjtdt-Bfj{Ei9uwNMOn z%Fs6H1CLDO_HA9@axznUZr$bmr*F>v+}rL{&%308m z>ZPeU>R!HV4_>lvu~k06nwP(f>mF~wov0ThEFIX1}8a^CA%@OSayzcQO=-)X!zeaZ>@wMDOZ%_UB%UhBNXup;R`i$-@&DcOyXyUS>e(6!8QQLNf6ms8xM%b%X#L-S#To1E#YMuJSFE0I z`|5!7k0lLB0uH%WWw$Clm*%E3&WrfeRQ{Z!dN<>qA2BCVSZ-W>tz@y-i*=&+G--|7 z`32vWxNlynVL7Y+=bG9T{LfAP>cq{z{OL*W9gc#9M~*$8A>{KW%=Y&Bs#m=AhSNOP z2>f!;~Pcxi+$PrV`JIpo&zcoFWaN8WLsOz4m%zH?aO`NUn@1f%CDYR^|=1y=Hv2S z#mdin1rzMAL~K3yA?woBseBiGgUjM%Di17B@4xXrhf$3=BKaiuyg3?Yo26HO{JbPj zGvCJTfVk^Q>v^`X4j6)(-+x|qXj=Exuc^NLdHFvf&N9{yUnYs${ERU$V&eI<>w1>@ zj{kWLA9@)W*S&Gv^z~P||E$DZgH+dqRQ9R&pLbaFG~^sSyS^|c<7;}}*D_{_C$$`t za<+eZX&yT_e_BVbS29D$#{Z|YcVu3e%zw z+Z)_398NLW7=VweTk9^ppziA(srmQ6-!VLFc4~fKO5)|u%j~`tUZ3+;>E*1rxoe_pN>d86=8^4-~U?NfUumH4yT zY=6S4Z!ZolRv!FwYlz*e*CgJ0>Z;eSZMPoGIcXj(8k=;_HtUE0>l%somcIJk&!2Cp zsZ=&+iqVL)a_X#76aBh1)PH~Cl%AJwiVR$jFcnHMwAS8e$!3|svf3s0or9Z*&CmIV z+5SK6NEBw=DRf|h!1bnyac39rx$x>>Uc;lW*L*h23VO9Hz27R~&1Gdbo->oXQqnhX zye!|c&-#--}tOu2m3cDHOniBSZJwNk{{OkF)pPjRvI4?aFt2x>$+M;T? z*T((I$w%Ao&8f_mV*KhH``}Tr&h0x{@sD){TI1Jjs`*{EdT!a}y?x7CBb5A@88m(z zUIuSC*)D04mG$75cYNRe`^%<2{m7v3f8&?ze-^u@eg1Yi>P}w6BYmePb0(Gph9{-w z-~WH+B75EpTi%n0(~_6#-}&-q<$h0X2`bK?Jh%{B;#Eri~NM& zhjPs=sz0s0)-q9biPyH+FY$U|4*K)vDZfhl7@hxi&&{UCw|FO<)2xP6;Q5mOtP@?t(VNL8zEf-(Z(iM$_$uA!ra#2XHj4Fp);rC&=!*0Ce?J=P?-kwovU}5V z#mXBscdmpp$b7hTk^L_>OFCmj`pNFrd6!@PwYyTe+~Hj2&o8$^=ee$DdY9Awr($oh znr*%-%gWvrvQ__6W7amr{jpeXdTP;E%>~svdIOZd-YVYUQ}8(3=ItKc%h{UwZ*`zP}JWyshBFwu1|g^?1&+D7qg!B|)em zbXt{Y+x?k}{u|=?w%nU$xc^RV^umMh*&?``BG1cg{r}>PW(Tj?-h@~4mt;Sjrc}h( zP;!zxu1@bqyx4;>@o9gx+tWBctPA?Fp7-3`KWD$5dwO<%b;|zK`e#}G9{%`xXs>bU&_1g z&x6(JcdX2_xsuw&-Q_k+UGapUfiLFS(I$KGtmmSWA2e;f#W_ufJ$3R0-2*%1GJlKi z{=L1;axwp1E77kZpHJ{yuVAYv@5`73o{j1HH?cL_lqXMYm5W@~>JYcJHOF-MEv2L~ z8K2%@++Z>fv$hHCS3m;^`mP` zs9e>r7k~H9d|Qw-`Rp;jnSYLnKg;8N;NfuZ?e|5~*%B>JGs)axvujfM`|H}GrZOI#fBDPiBlWeH|76&I&1yXtd?53#_Lln8sh9Ki zojWMt!RQjn^x?oGp@xEOa;@QF8)k5q?fSAU`Oebs{m1)md|%G=(^hPD`p@TKpvCk` z^>OAxzgW!ePpvm%n;q4x!~LS|kYRM+Y`c4Z0vz)&s3Dm_=lXkBeS7gw z(^*!GzInvmFVW{vcPFY~9RGCq89cINg6m)uVm zuU>FvtJkFe+kd?@-}lf#PjkWw-UgrMo+}&$pZV9?Wxs!UGhw%Odi@)fz3w))Nk6`qqPC#Tl;_f~^reCVeQ~p+ z4!`DKut2VLMd0fBWvBo4sZ46pY-OB~xuG?-g=L$x!^16e^>)8azqb6K^1Ofl>Q?Le zT#{DU6uvn^9CT!c;`Dn-Wy~qpvn|(iD<60o6)?kc!==CHXZOti+WB(f5n25{n+M&K zw#{V-bXR!X*D%4S;o7UM!gua)OfX+i;P&g~a`F6y zXl!S<{nlK&O~g~p`OE9Skzy0v^BLS6Jk%P3_x{ihGOan?o%ZMM8-qu)!u#wB4u?E( zVffFSaA1=79`Nw$h4&5H7ZhYoeYVkgUZScyBT$Q z4yfGlR!&e9Ik!?}>ixBazm}YrIjJBQXvM5>`9LO%iQ7Ye>)5i}`=jKm4*q${e)wBx z(`$w`o1V1m&W)C0Ow8Ci$6QDGf$Y+P8)1)^*=LxzToQ_Vd*i?N)Okyoz5aMfUA}oVvXhHE!FYi5jF8tiQ`y*|`3SpWFB^TB$-3kuJjSWCNuPNqt)-FH|= z(patZ!?zkErA_VRifReimjS8?lNfoH-6 zh71P_d9z1T!&og#pL^&3-T5`q>e0c8>}yOC>Z+7oYA>I%amix&Q93Clvf+G{=oMXU zzNnA!H?QdEfLiutoVWJP*ZR6%DZ=;rj%#-#*e}XI$oG2k$?@%s9q&NXrC-X9Fy{B# zrOar$)3j86VWx-2ZpJsS&(656;Qi;i`Pu$!XSKcJR;m0rDYg8*#mS2To(nE8Txj^q zc;;)@2}$d?qSu_)+B0Xi_gfXb-8S!(iN@BsUn?_icD92`+pUtXwsSJ9_hb-HgOZ%>wFOB63=W1t+*n873^&YRnf%{V?ontxqRl0BQQ%Uz=N10E@ z3J-r5YhB4OZTr;8ZVkG7Q}6L!`0;|R(q44M{k2vV2P}=2Z9aW*gU5jj3>Q@9@Jp}n zY3Nq@`QZk5%&K;V*pJiv-|w2mKKYiPP&7xqc=gOt z$iDa7oACE+`73XaMNtX{AA?^rAt3t)}K>&c|Plgeay3tcz$7X zc<&HfdpytHV#!?ww}nleFAtS`(G$-5f8dvZX~mQF-*1%{>4~-W#yf(dvo>QGj;7g_9ykTj5Msi398ZW6N|VP zu;i;XJ!hFg#yo7$IZr=h4>7{twEIe)@FF z@bLAwURv>f3-)oSW^1h|RotW|a*b_+S%WD9&yhvPa?Z+~{-)W`p(~-eRkfwQzAOIW?u64-oNqO%;|p_dp7@A zd)?yst_4+(qA6%@7o!sbkb_kl{SY>b31Mca_wGlO?lO;t`jdscF$m*#u_2z zy7>V^52#(%Q^mSt?^@pb_j2BR@$YSO;82{k$E@l{U+19^hHNulzHf7`*(@m7dxv*I zDoZR=sh(Kb-?Gwq>Yc8SOc{9GkN;!1C&}n-EZ@D2d;J8>A7SD?o8!1#I5f@IgX$p$VMfVE$Dglj-lM~9aPHCed#XNRm%dFl{a-%&~C;FiBlA(vPwKRJ@I1w;@QUfy$p{RAJ)h`xq7Ob!@;Wi?S~#l_sy=GKC#4! zW98x3i`)B~g3=Q``4tYl(9&P^diFoY#226{rF(hR{P0D6(VzLZ`)oGKamotdV^%nv z!EMguWW>mIeYRr$641hmPcL{E=o~NrOX+RDDdo+{G65c(c`r{Q|YZ&&TR8^lW$13?^qhc*6?seq*y?jaszk* zQ8E9ZTZ4S@>%(~rmigVL<#K1;+*<^K@BKQd7{4NxePblgYe`C3}(m|r?cl)it>B{T(UE8TyaO$~p*v&cS|Ce|y zy2Ds-KggQlj^!>LZVvbSwmq}{GXA+X%XqybTm9Fl6Q}<%xLus~Rj=g)|Mp!8w_KEW zwoF)2S$Zb!^w#=4k181czf`jS&Aj9C>*JQb_T8*|H5)oFP5x8UTbx&OyUWL^D1eLk zK*a(bZiBjFd+S@#Cr)o-c)a-UmF#Hk-^^R0)(iC<-|g(i_coJ9G7FLwmb&*(WLW43BjrzuGMdy1MaNg&(T zFD5W;Njo8(6zSmo>^f-C_lEdl>G;Ton`!lXGiIymE-GkQ^YLfx`F|Pz^)tOyTU?I6 zJ}z66pz1r{VgBs;16QA~duSTB;W_9a5xeCt(+#iuo~9wQ^Vo#k@~3C*KlWaF!*Ig% zLsL1+kGB)_W*f@vmHgDUMY$oBahlsj9gB5tpes@<;v4Gke>(JXyOgJD3rjE4ldXa6 ze?nOPx2we`?EHNu6|@V&_8#wo2ED*nN}1v6>p}gWHGTq$(=^sHCfHPbV>hV#T<}He z884{7TqZcdUH>#w)QWJ&f?w0J-xMwCk(>-R`M9)uefHNady7mLeEa-Z#!(qsi$uOs z4-Q%>v-_Lm@YwwO^^0d`PIqhgc(Lx)*0odJ9A1Ap6|!~1 zr+8=m**_Of^l|bq`1ANibD4nWI`$7s>-0tH&OTUX?_lwqLyEDsLdkP!a_{E+U8gUW z2qaCt^o1dV3%m*Ya7@3g zFlhTy^S(_Ci$SGk-?`P{4X<~H79BBrysR!oWum|%-u1H87s98gTxYl;_2Fr;k9~GT zRqpJgOEtJ>OkI|F+$!C_?$$*E&O$!k2DJ_L4sCn%(iE0VPPv>Q#kls;>+5&eHb@^Z zxZ$nr@Igx9|IVwdm*1I9a_7(#VLg$3oX=itksq@`#>?w}f5==uAN5C1$Gy_IDyku| zVDbg=DPDD(zD1h*7MkcdMX~WR_%1SK+99p5I7|BALn%fb?h}i*pK)_IUB(=uV38d6 zefNIwn6%nT4#l<4rv@Irp4Gm~iILr;`|Ia9Vfy-@ts>FpUy=pq{5pAPU9p+8b?w5% z3k5C(Eiz$v_ji8&stI;E5g0}6?NxUfK5Yn^S}$Mq@XYj{CxT8<$FgPHWtNHPF@9jZ zoBZd8jPXg(DMoSa*@~DLRv)5~8r1vB=z9P78Tw>$o2bK8>Z`>r+oV(!@G zeQ8@@_T19Tllz_s1!)9IGpu8m%#Cc=828)j@Wok*@S1GG^i;-U7bCxRd8C(Xxo>h* zP&~T1WbytzKR#M6n>~3qW6f3V1JVH&nP)fW&D6-7m*>$TuHwp_!TQj4@s$rZH?axD zu`}c-(U${~~ z)2UNu?H|0hTpQ=toV_REeCAnZo6FYXTG`M}h;-Z@?h*TYpT6Ym=5|7mCDY^m_nAOoUa`-+%$ff2O;p~tE8$kI+a?Z0w-tufTlasv_+Y-q3C)Zj75j~( z855`49lId!&~SXhs=(~I#xL#M^SwK^Pr1fWV9RL2Wc1|f8L_r&J>b5q#+*9#UuWWz$UgS?Bl_ml&?^k=p0WR4TckEJw^6GzJv4-Ecc@35VrB}kl&+Ir^QN*Em z`U7|@Bl*IuX8c2FDW;a#7L z931ZJ6XV{o8Jq);NtpIB?B5b~?)420fr;mq?0&mKzkmLpeJ}qAIEj6Hb;7w!)Ki~1 zWp$l?%CDFOf&$axW*;g>ep4_-Xlzp>1O{SAMDOzx`ihH}w@H(}y_|BI*nR9v!VPxX}!#m~2% z|CO4M%(&$$?-Id;HQQvKZ#m{T^NW^^YVOMyThGs^X!`Leznu$9GV6_i z-wQlIONCENxLy}|pk3+J_3X7jUa}gcmGv)H6cW;zD8+C;z_(rOZhP{518Dhsv0ib{ zB(dM8{#?yg%=d6}XxPojP{N%OQ$73F(X=&^swt9jcE1jsovdQX-}Lv_Kbs|&j(<2O zXu>4b@~tCi`tnYz%)7>sr(8X3mCP7S+K>NZkbAjxy@|};JG=?*`PrwLKE2)w$+?nW z_VGSQkB+mbUBqz9f2N{J7^s`{;m^88b%r074UJoq?$ql|xyixtdSjzo<>u+Ecl10j zZTu!_{`c%f0glc#4c><8f4`+qy%5t_!ta*6_u&*)iRYz;%z=0)#fdxx^M zZ}=a0yyx~>IeEL3`Qxf@>%ba+UL&@+YU8d-=VY ziB%jQ+uZwaBp!HmdhK6(t|G36t&J1x#ZP6kxNMVtz$<8(`F3+&qH>s%PJkElf^QEp zSy~oe?Y&g+CGCXtB*6ol7`zu}h{;SBe9+6e;e~yg=7W}n;(pbKDvCHX1z1n4Ud(4D zv+|ET%j|1ioG*(P)@L}nHHbg&SkT*0$s~DuTfu|;>w7kQE_ToN^mwVUm2txxl@0a| ziBZOoL@5dCIR_%zyOLj)DHS+q3G202#54SOHc8&C*l)FXRGP9v$ED-$ibh#` zH|OoVe^NxiDT6_W_cYV0g%2ed6=yQ!eMvjvt$g4`hW*y4Q_`7{4Ljr5-5PX$-q&05 z>ii^?{dYe#W$LHQP;y%H>C+15Z&N~g9q&v3-^cRXGEFEdPWiy)yA1ESKin?8CsDGm zq%2H^XSTp52`Prs1%d&*sgVvARmM-Q>TnkJd(_LoxJ&fPj;B>sbsK-( zcsbunN=tDGzX`)EfuQ<^34W8_eZ3Vf)fabo&mG=`nl1CIbe~=|Vfu3^%W{3SvV&vd zs-TzoZE+@_6O}VtBo-a(@rcBK?#QF(E7ZT&u+D~$?;pk)gZSqQ(9-1b=lqG zysB##FK{TDE?`{1Sk4^cd{~p);9T~EjX&}=WuJo9@|Q@c>Q-*O#8I%Od{@xR_w9YN zEBhy!IL-R-s{Z}fIZ=ft>a|w#k=N%M>fF15#GUTee6au*hHH=CEKlqn*%$DWzfmzA%Bu)&)2eK?`|yRBvRw zFiY6q613ib%1w@h`i2c(qE19_5PWd_&bAbD28J4?3HDx>iqDBXzyE91M3Tdh>Z5OeR^<=RhH$;6nH;I5kAI*D71PpaZ;+4DPpHcs>rQq?b!*^)YGmGZ(T`x)2V*(IF~UXB>MRq|Y> zKIfH>ujktS6jPtXp*T}>D&vEq113zX7T)0%=)1>LsSGca8>Y=-VAvqQwOw-s=V$Hr zclSL0aeSBWrAZrp9`EOm+FH%v=J0So!yCTEwU#$IGtC-D{W*qp&c)5A zmM_mUN&Cj+9>k$&sWF%FLDd7jQ%q5duWt*JVtn}G?1b>m_6_%=il3RD0JkJ=${*0U zH7o95JJ$|5FJGCehdtH3PbMW)RGsN)WVWBNKVIpT{nSGX8Q9FM+uR)5_8dzqoLSC% z#gs`zA-t6T#lM$s^)t+lUyK%Z(g}!SKJb+#W4_9s9}4BdXErgEyEXjJUUh=2TB|k|sKCA=P znlH+ye&?9b_v?{muw22Xuoi*UM=}{|@;5R*xZ2t7=3s#|cmYYjI@|_*+tzGnO4z1x zXHwX$d`ZwgF1JmdiJ(0s)9+Q6h^xE`2>fBza8^q0!BWQ){}>*=*m~?W=ZF8-Y|}r@ zZ|U{oZZr95iO~5nH+}r|x0T?~2BE z;_Lr?k2_tu-lF2X%2ekmD-JWRk^cAG>E|h?U+uSo-~Ei5!GEywH17n+#Nis=Spt`Y z9+@*3^=$dDiGlyiF58b6Ks8a~y$O)B(JH@evTtCo`pheVQExh-I}{LmCA-pJERv}YYeUmxqYJd+|Hlx!`QtzG9|&S zuN!|^HOoQ!`g?=y_ix-bVY-|6vN^vQ7^2R5x;Tcwde(m#Z7%*6Y`CeERTh=GJNb0? zGP|>P8S^S?zjQp^z3bWgIKi#kHQw+ZJjZaWwEuhZ+h-?FgC_RP+!*_vFZJ=Bn^)C; zwNcP1%I_x+!{NZqIttGBJbD`r`7#_%_s^<>?W>GMIS z6f~JI?qZm8-85t8{k)>6=jvhX4jh_OGlc$6g)HrF_bfh1a1ylntP=!IluDQssyJFahGg{6sZN_>T>Tur}0TIJzDSm zQbQj+B6{!NYH1(PJOo37$bs&FAKe^rK8@2|u5`cc+_UB8jhF9dF8|zYef-VUefO-J z%>_KoStt2kcFEQ9%4I$Ajp5n}qeQ(jW(oPXSHJpBF7J9ZCrtlJwDN<<6(wKTp8L(s z3{_A(qP@tRp@;qDT~M}5ta8RNY}$UUt%mD?D(lxt8kL?3`kI0HYPm11y_UWYy%+iP z@1l85AD(@7Hsag|VzaC%tODA)(9K(u`OHWlj zrvdYES;7yRaJZO`~PXu-QT1(n{B_DZuep9 zg&%WmE@uD!waom=x~#i7Tsw>dFKzs{bMm}R7NIQ@7&N9b2B;npPF>G=;fZWd-WuoK z4Pn<#GaZWLmP|YFL+Oa#w`XOM+CJ9#ilBo*EpA2RPsm=lan_FOP4aFIhG~WJ69ea8 zmXc)LW^Wkt>g>5q%DG7_EI%!mN#8P3eo)F<`l;=?-^_%{E)K;;P%F4A+Wq>zFo(}# zCnitcRTihb_iBFe8)?SGHLRjf@))eEY)zS>Hi=0|GZwCA-Fuhe-;1qtt|vU*al1A2 z${oGFRd&~pSMPmxaGK7(S?=wHeyhzdS-lr|^L&Qq3S;GjbwZZPe}7yKHs4kBjp_7d zCl1bzAZdmMebBbeTe{o@>%)~Jek--4GCnn9x-*Sc;%RPWe8qxRgV>g*>I0Vk#)X{e^;{Zl+`*5 z>f@^28umZmQg!&0?dg84sXf0gRc^oYKH){n1P9M9u?Nyzd*%0-HE%6WyRcgISioxT zz3-ld>1y(FIIn;EGqm9)kIQDUU;AI|zb8}4+Xr6H_e{=pbHY;C;BJ?MUCavm&r9d&rcN&Z zc*rHJ{X`vA&RIe8WG%b@jK2^Umb{-}CJhQ_RcK z-C|jCCa$3cZ$Kv!t$O-Uz)49tiFtvSWiP|qQyKbq|Nc3+cl*IN{0U3UyW#}hs*{Q` zub#VI1e#%qS{rJu-^-9ZYkjNTdzr76y?+^ddK*?;KQ!roRoH~Sdk^lcG}h&B6cEx3 zlwuIu$8LP$w!)3s!u+Q}%Vc6i3`G9xE}fb57BUA?|9ib(L9%J#dRAj)g>_pGY+{h_ zZMZh$=u#c-9|0nDdk@||4X%1lZ{k!G6kf_0u=L;e1l?Es55Q$3$6v-hH+dIGzWE=O zP|q-pwPM}&1OFHb*R>W&FzS8V*;44YdiwSMn;72i;cZ?T&ACqEO55aXw#A=*PfHbY z@^ZMs#_-@`9>dv$Z|pD5p8m(cR>?c1EraDrJog)Z0q@9$iE+J~8h&j~cF*_N&A8<= z+h(yx@Ax0AdZ7Ae>x2D*6DoBz&P*~Zsd@S4q<{1@R}M|Vi_#3C87yC(rPepN*0+U- zEtq@VA=Be8V}#fQ#q|d-KFt4r_2Q{b4H3Nz-`F!mqSpOmxOKgvaV;zBo*5l7+aFd= zULG;ci9=J-^CDAJuhV<>{QvWxoqw%lv42xqaX__ON&Gp9S2)s^@wR7d_qMr*yWdd-m<4fq}JsFWa6;ds;j{on3Q2vR?a58$b4VzK^K0cJescd70rvDPwG!vcqPb0}ku$Z`YSP-IP~w0VOJ) zPf|WT`>TB2F6u;_=jbTbopO`o!*ZPi8tboaVmRwk+4rSL_eVYpN5OQjOMSiPW`1jV z`lUsHNg<4xVZ#T}A)#|6^dh*QK4oYTs@ycH&UH*14bE z;hog_Kl2Yhymr;Ba=O8*=SHteugf2B-N3!H?uyRrvyHNQ-S)Gty^J~6mM@T-NeBEi+O{Ng7dwY zs<)DiQi=VlnMPTMKdawOuZ(CBxHRR5H$zt9Qk?@=-ELbQ{N~*IHA}O>_oAu(dmn?fz8P7V!)+Rl<{+j9SCI)7~ zukEV&%&$)9uif_e+@7E6Eh4T{tQlfzxpijQe|21)wqoP8dwYEE9kt&34RkP~Ug%*JD@f&rf z3pg1Gu4QD{^6N;jpR&VlBdb?YUf7nYyO@}}>sQNV(yv|#FZSE;Hz0X$ z)cY&-3G!SQKK5U=yFbhPe~W;8XBGQ_OIx;0Gur<={?9ajvHG5uyfvJ+&_J^2Gi?WPy3&zIU4 z%pbIKPql#GuTL{4-`}p&Ea0>zP>Xqi*K0qUr#jp(&fZR0wl=2a$@SOIj?MbZ=w!n5 zX?^Rh>0OGkZ?d18&XB188g*iIq*y?G*_#)skqw=3wO@J}KL2A#oO}6({DMxwg55zc zU-+Jz|1JD2z3Z)N_>rHsO zHK3rL!M}_-<2i4k?iJp+ZT1c_x9d0i``nnHyzAi6Z|oXn&wcOgW}Nl>Y?xTUoO}N^ zeQtG&6`P=S;;QGQIU7FioP6)({5}DvD1P1syA3)Arfo{AG(FA4^X1p(zv1drzyX}| zo&CeJq0#1GI_%St{ez#fPkxPd&y_Vc}?%wRq!Y`7>n}iY-4F zc^jlIaJ=9xtrk4=kKqmffqx8%>tfCSGCl&;ive2%1J3k*O`15-UUxxV#)H3%BKJa+ z)botWP93qUw&GA!iSWA5yg={`|AkezT)wdvEWGY&@SAzc0{e!)0lA@1Be)B{u^R+G zRLB>|Zv4ym=|!pF9hDNfhRb40v~xCs&ddW9ik+$S=r`GtX{JWmKHTD)CZu zulVDe|7QO^3vOB-yDG161Y87iZnSsEF2BbTB&J}b>RIKMS8}drzcjaMi@^ELOAH^f zPp412U?j~5Zd4j)Y?VH2qJWzPi6GvVk2` zXEtdOq+Q-+@+1-`AMn9nK#5cuJ|6W*JXZp>&WrO{JHCuR(=hdB?lK%O; zm|{zdb0!bNf)|r_RT}g*+}m?YdQtO@PnmLO{xL}QGUR(jZD~8csbQC7)NT0%7q(u| z-%%;L^_z@D)>Y8OV-nxkHFnrHOnT00kvIACa-Yo>p)CSJJWFeM7C624T(n^i+rh2Q zpZgDJG&^qiy))OSa_94F+v2JZ>d8xIEV*8{BJkL?zw>Se=D4p17k1NmOBbFx`g}%x z@#-(mdoN@guAl6&`N5-SWrZOj^OJWi{OK0-{6)XZ!*uTJ5p}bD^&XtHS6s5^$WG=3 z8+N5G-fqqpXSQX|ZpNz5({|f8yxr8Wisk=xC(lz%uh`me%QvJlR(=t?Ex+JKoM7R5 zwv2h^5B@Q*PGkLY@;38>BGs2CK|ySLI@pOr@u1*xMusUlw`F=6X8&dUbMxP7WA-=v z0*eGE)a!x<@J};+dbxE%_-362|K4ngSe<5Yn#t!=+H3v?`IVDBGp@~?ZavA%sOoXy zE$!8-Lupvk36vGLQ`+U zp=(Wvi7p}8iZ#jyT5~1cJiXiY%$veGsv0;idZUgyq8(Njp@QvdJg zO6ge3>c*!Fg`7M@T(THKK3)cmE$=y1@(I@Ws|NXcXD`F{RK{bV3Eo2*vsgFVJFG!1 zy7acHx;!@%KZbiTZ?f4o+dC`fs7MJXWt|V zhR|PgrZritOy}mi$Wc%&cxY2Yku>9@gWIR1b!~mF@M%-Sn=0KYtP*U$w~F16KhP=o z;BRl~%TrAEL;}_Gtjc~Jv8z_#P;4<#)QoE27Ll6EIjw4I;ReUJ@T$%1+RgK4z1k{! zXV%W#PkMptS+^eH*|7Gy>xS5+T%p=WuD+jnKl|W|?F&jlr1;?u zA5WUDP9K-s`oa%2s;{=3;(8kmvW(^4A7Q5s$-wsv897?h7u!2@>}LF=dHJNYTRnsM z{h&`98(~FX^38v%*Js`2dAR1TO;tB&!eaL8In(T|&TV}VUeDm2o#@rIEqS|y_0Q1L zLY#_AOk8pqLJV*5NWPhNUT5J`6Q(WZF7A6l9=^fxq3(D7{)ZcN7UZ#gW6#(c_I8ir zG**j?u%fT%+}rpC7dHN6z5H&QPPc%Qm%AnpgTwP9hyO9?nlkO#$&py!FrkOxw-@g( zW|ysyc`JtB%tx-uJJg#qFumblVApUW->f?Q!_P}MBRA@_3OIRrX7V&>DNbX}cz(84 zaMJ%vDtBgUN-^s7G$d{R|7u?$Cny{0M~gl1T3>H|O^2J~5J$l#u`i23-R{m$_c>m$ z8LV5o`}g$OpxrJ{x2v^G2nx1hUeM*OeBeH)tOK_%PJ`+zfu?&rm9N*erf&Z6wRh{$ zJ#4~{LHi1vU-1h(%41Nk`pUV_zTuG0M6cs%;%91ZeTo)z;&ANbZO}Sv?%b7Eu;1WC zt5i0q_)?f0_l-T`OS(rY+SDiYaO&%O2po$>q65!x}e-guje&6GHkx z(|;?cT^4rYcQ&RdPQefxfmQ$PX!=LVDJiy0P`kj8(NI?3kl^>~ z*+CPgs?WEeWm?08U(7e|@?Q9zG$Z?Z^FId1Z(RxX4U2jklCCvX)-#+<+{?Gr@7|w7 zFNI}zx&@p%*g%tjNs$d@aoTPU`vgVyKfUae{a$9uZk>d{498T)wu9fUL&~#*!O9C% zvlng@6!;#cu?@7!;pv_>SC)TMw>&wSy|KW%DAHE!8l_W4C>eZ!)1 z=8Wg22T~&$7^X5@-@bjj?EaHGC&zErnJeJr!M4bRp<$7p(+&9rbKDyCZelnbki1ui z+u%g@f-6e}ALOs+@Goc1KrP9Rr84$wsV+9#U-0Vhw538$948&W^fQM148C$^ZfGR~~D;xQ^9dPCDYopH+7CQnEn}*LW(W*}m6btlw+- zNlNBzlCH$_v$kDn2hW~wse8F~#&v~T5$pd3D9?HxYqMwdQ z6UWEC4A4o&65H>b2Mv|%+`sh8W_yR%rgFZcz{L`5vKcMxyrHmE;vGCI!grE$Wl)X)|Iri(biGl0y z)6+~%2269-v}*ijF3~RJBVGhWXAXYKBloQf@5nk;x5w2sc#INRHxvF-n>mT8-H z7MSN-{$@_u!F#Q)`fZQmG**#i+s*UABiW#Mm~lNJ3AE&6eOjc$-)y#( zd}vvFe(smPcOimK9GuOcBpE{Y?9ceL+MeMwQ;uf#v~bXXQ~tu+^}J0t7T*dDB$E#;F8IZk{y&{dL=^tH}e&)ueUi0c8k6F%Fa=& zyTKlGa`&z>+ZX%-Pofg)8o#qIi1 zbLT8r`Sf=aL%4MAYnI#mrq@<4wRyGm!O3K1?%DBIpZhO%=DILTF~1{~QFmrMH>l9A zJDXdR%9u9$@7G19O83PcoY1{~EA{z8@pGUCd&`t5m)ENIK2b^TjHX``1NN zrQNR3HE7Ap>$!Zk+c>ufI5{nv!sak_hu*ADl8pcI{$HKhYy=wnIrfjiY-?6}&mNrv z>+bP@y3tZ?+k#f78T@8`axvN`vLP^T|0$+rAzCkPRQ=g9UDk(Fv1I~7=M@Hn?^hRA zx2TKzZu`yrqqK3l>6STe4R4RLNwrC3UY*DMjolzDD~Cm7hgi=iGjPo(Ge3FRDd_l* zSSCw{4g1o*+gW!?>hAh;C@zr~{LWyoo~vi|v`K#%KfNjyd{dWqQ+`3g-fzymMq=@s zbC%8c%XmZnKn33o`2~Sr+LbPSd^hX!Hn~%v)S_tGQNr#JACtSmn452-&VsL1*(}e0 zO}pf}HTPe_H}(?~u5Uo=_iP5`kf`fV}R;x z!3(qczO8c?cZ_t<+xt^L`0tHRE9Y-6=Q|ZEwn1Y{g7Sh5TN5HA1g}PM+~+7rj6Mch zE)|mlTK}~8!cKuXh8ukA_JcMc>z-C`5fHKrlw$bxJop>?hqD|RXM0O6%bCAC+ZrwQ zAa1?ehHs!8UL?(!_>H~bYp3Y9=QHvOLjE#NVF-7eG@oy8!7Z87MVyK)Q(8258?@5g z8sxz(#^;W|FCAZKXoDclwdac=PMFZ8`@m^4|rT zaPzIUZ(zvRnkd{3S|9mkpA&~-OUg14HV4tOvSr=>uRGQ=yw6|uM~C~uIqA&$hHFz< zO)93ZxZY%~tk5%C^Benza~v5bqY@$;&bu`*d=6c8&iVGC`*lwb-JBmi&5=WK$&o3i z7#MEIJLs>U4_YJqBy`zi^PYbUdDocar6u+XDpcu<#T~nVXf1u+YTw}Fxxg*2^5dEP z(RI;I9EwY7*c?Q^v0sps{mne(GRKGg_6@gprq(xP{AO<1A?*MgrP?6>mr>+Hh0?0z z7ds@sN7QLMaVVy6dR}CjvtU82=5q0@2^-D}>NUlRFqp~DHR3!clogr3?ZK^uKiw18 zT(4WPF;4d>xMDI<;MuD;W!F!c&-J=rV67ui+KMaqY`jo;ue`=mt9gGDj?QiGn+1>4jx1?yDO+rwb1hGuj|@P zsf@Z>+%MP+@)B=-J}rCv{I4UQK7a~`Sx0gi82a>_9_e$hm~TE`FyO_v_~wLZtXEFK zdMWqz{Jef>)2l<;nTe4N-pUH?*CIP71v=}```7dIp^y`YB5&t?h80PFm5%%gTDtXq z#Pj2>^$h#y{ZHjmyKel?#M6rcd>Bp+5Ns-0sOAXkE4AT;|uN z;_vJ~JUajA{7g8S3mT16T;c(~THuEKfewM+%v;v2yN0z$*^$av_NDE%{DG_gmTs^= zUeav*mED6e=B}+n-KM`cUcQf%V^eGqNb;G&=CBcTV#0yrY;6n7?e%^$|JeLDJe?0* zo;3VrgiZ$Of)*A=2(2nm7b?lI%{vx6EmhcwLvd#3B?gABeH@AP4EI5Ur%MGVc&>j7 z8l+_X#%?gHF@wkH8~cVNNSXUhI^yo)xNtAOd(R%d6qfN&Z4q!P5?sp|u=IxX>m0X+ z`e?h&Prh!}aj;%5s;qFIBP#mX1paBPA$0YA zo6jd}p4*pd*&^W75$cu9z+e~Ca^$|e!*0__;rAnMbEicfxK#OeDyu}B)a%s92FEz& zrFy*#_4#WK-{+m+pKtS*an>^t8z$Ao`>HPXu)Dk4g5W46+|AVi+pp64r zEIanjOp0{a*~{?pi*z=3rLsQviFMaO+n)-&&u4PwYaTqmeP_lqmzTWCEdow^mV;`^ zr2I9q-`GF=OFISW804?{`-cC*u1yVW?}ffb6{NHLs5d^ksbQaf)#B}{*^1jXF__9v z_{{c==ht1a>HfdYe7Y^*#G!ajb1OKwvtecH*84~!9Ol<*auU9{HN4mxQ6cGk`ZUwD zvjXRCu`>7sipu=H>YctX%UoBnMc|TE27}JBAKe1q*c0j-1in0T->tKteuHD&?x?NL z8~!r(Om({r9u1y#Q1vbU2l2*NH=aF;et&Lyn}8FC=DZ~tjQbWQ1e{41SRAUg=$|4;?oBr=r0(ZtMZh|IF|GpM|m>cO}?$Z0(?{fS*i@Iy-Guz!c6k94dcpKP6 z>IH@BBOU(k`Psuzza3j=VB&Pv8&_}7HvbivDkAonMpxGV=5`Xk2re`J4HQ{Vvz% zTg0cVn4eR9ac!{CNp(gtN@L$ANGpIDDHoWHo|-_`Ze zr=%TAZ#eV| zRNyZ#*em(T?Uww3tLoE^-rp?374E&L@Bb^;?fYg&swuVz2uTD=Glc%R_i3i-l)sEW zZX6K!^6YqOeS=fAs|2VGy#rJaNjx!~F=I|u`f`Wk-yWyi&5RTS>C{+iz|bJD@8wCM zTTTBMSnoxcpTGS))L-x0*3WKvEIVSkzx`rv*$Em9%V1k%$Z=EtK$GByT^xS@UL7@7 zkC>t5#G$C-0XnJdlS!*dOa_)p?a^>!CTpl=xb@R{)BKA)S7D@e{TW0`)3&_mO1+@co~+8#WUWx z=dk|gH#N}E>x^fqjJ5YnXMu*qQq7p&V3Zowt`)na9gJ_})-zO!crH!8-JQ4l{HGF7 zMs*Z^n#{ncvx_O9>b&ly?o%2E9XG5FlzhEaJ}Ypw5$8EG_OP#M-0Ng#)r;tT-R1E0 zxBgWNgIky_{lgFb>Hk`9?tbr?`+n6$eRs<4#+Kjg15G(PoiGB8#_MN&lvjMqkx#zIr4XKQ^@n78Xvwty{a28x_a?IU-qx8)e|Hf$D{#gP} z9G%TScpJ318eZHtYTx>II(VilsyI3J*`|h9_y4@-e*m6eTx8DV^8TpB`OI1YpG@yd zfwy~Rf9+~c1l6p8fpW|Y9L44hpa%Ynp1!|~r_xw%)a5KlTrn~1tz|+zL-NvNZVn>Q zMMrBSf2~d{-q3mo6r4dkYF?}~c7ht2Y){DH;)7(nGmD(F1d zT>-C>|L$74=jm(y4~kP>ReW4Lb^E<TfGf zzWJ`}4a%@yj#Jne5~>6jg^MN3x$dy~-tO6~n{^!2*T<(ax>ZJsO$f#^qOcy+Q+aM$ zQT^8P0{^_JDiVLMcIW+_ow~C{z{z7}$2A6qO))J~cG@@mToLms_iu0M{BYZyH?E)a zR)!9as{dw|IOAZFt+?VJgFe?4&)dEG*IwRa18VC{ami%}StxixI_gC-GjG%0o4gA& zm5yxPkdwiEnu(|R?tP!ByFuf42i+VJvkz`CP+GP1!B)W!JDJYKWCq-Z?0iY}1UV#A zvy~Cl;1>w5XV|=n;csB;=e6_w;_Q1HuHA{4UTp4=f6w3))0WRtYg?OsGgoYqcCgOx z$X=NDVbfnmE}eWY#?r41Cbo&Mz4PmjH}Zhoc4f*bh85u1%?0LG`$20Q5VNOkSGAkK zDJthXyT+j{0uL5;C0yxyoL_z7(```NNike%!oV<%bw=y91@;Zs&9A-Tf3RBc!M9_# z;_4d=K$B=2>>W;DdnX-GbZOi4b8|HI>I*t?C>~*4YRa(6C7&f?uR`e!`2)YDv+Yka zZTakWOMXFVDkJYb)mK{|th0BJ02_FNW5#r+-q<>a@A>U<7Qc&62Rm{owoH(ka*tuf zqfPb>5x47MOJ_hGveW+YD zLAI)?xN?6GWNlV*X?1mLI40;;Irr68$t*jq#Tm^HlAeXlWI-CGGyq*S8nJ%brxn+m zqJpFZ+Tw11UX3_mRN~HmSLeO?&JRyaw~wowE?fE8pF^=lgsq&x*0riW4;be#B-v!|~kulWQ-_o=^j&RK7)T86WuTs{Ur4@v2nt%s&QDj}g*` zSCHAOH>K^;X$9wd&;BynF@+pYe_fh;7Tj+?;=kw);|1~heW9tKu}luf{N4vlznNS1 zik*VBA>N)h%r9fkV5{CGRyAeC>D#;5=j{0_dinllonBA`i*xFo?4`e(<+w0eg4o`!4p zeZE#+o!nguTBT?7a_e1rg)>_Z{9|x47I0hNF?~V7qrU0)u4Vpw%L&pu;gTdngTMHN zk5Y_!U9yL6{98TQylGQIOx5T6n?#~7C9T^E?UY~nzlq`To>`L4cX$Jo-^Z|8CQp5O z8`=p6kJCEI2rp-R@bzr`+6O;=G0&LF`l4TJPX6DLProu*wmeH)1)jXU1zB-Aqo4Om z-DihhhN&FuR+W_hFL|z=etzR0u@+E=N3c+ep}}0-_YMC7X{q{phTHwTCOoE>R@t)a zaGzL+x&}&Wg5ZY&Cey5T?=;&Ceb8{RQwN!@9VXA`ug0H`sTVNI24S9w2 zMsxQheqk@zEOtYFL58w}qwmTs+xzdlT_jn*-OL2k(XVjc$;p=i>p{l=3tc~Z|I)##RK}}&9>%_QH&|SAvII^4 zM0`>I&7AUh_zGm${&EZ2>1QpC#4|QeQ0N{_Ou(e|d7K{%__D_6{6X+2ECZZ?*=! z`^Ny8Er{oi+RYfm3hF2K-G96&`u#Y_gv0Cq>vu7)>6g~5g!LOHLeg2x?dtTk`U9~qsig9mcE^IiiX%A>F z?&qgS;Wd6Npv1Xofr~9e$l>rC@(0xS{#rQg{#}LjJFi_k|2AlS)q3;lg1`S5etzA> zkyhVe@SAxD$F>zEdMwM&tS-sl#|>Hq)glnoF7$@sg*RyEw+fNK-|%0UHP;+GdkJg* z*5o9tJ-mLp$;J6tP~OUu+pRh1X_<2Sy7&^pDkr*IueG_!I0+ z7K2K@fcNYTC40WTTxI>PMiLUAN$sF{L)9a98E$@wITAl|FZLUmm39 zX1fFG2UvdHWbfekmoZJ%b15i3Yi`||4H^Y%`LZyC%|SH3r(wao-LcR9G2EOd_P{Iu zHK_eHVam6Ui@Q_*Pb}P)}rEo86@I8v^$E~9lZCsY8OWycx12inm-{- zY=Ql%9oL%XgS)1n^)PLLp#I?o`-Z2R8bTPum)|}4vD#8Hl?OAJat zp*5lHNG?Oj$0HmX>lW`lJMjuvT0O&Zw}$I`m45Acb+vcZ-3J1_4BN90f?J|a)BZ7h zYHPB}+^(JGD`GxX08}+9`f9c_1}s%I5B}QE^F7-B?t_Y62JxeO5AC$pom_qI^;x-? z`y3e;K`r*^aIp&yVi(16XBB2#eo)EqZ-c^HyVCdI`EyVUzePa#R4N0*y*F$Z&PhLE z-BMTIuwmQ(P4*5Qpnf@ceE85mhM)4%D_C~yTP}P2y=C3C>}l=Jps<+K37VwKU`dhc zMJ(`XIQIf&>U`a4CXo*-I!fMD-OT-NDZvCvtR88LYIqW|<@uLYr(6r)%>Mi14&A-V zhpKI6%nm%dRcs2zcn)MuvhIrQq6JU#q~Gj3od=20j&_&5%m>yVtN!?w&EWFtc(Dhq zajQWc?|q;d(zAE97eOXMZzW{{J`glL+o$tqFnuOMb2i*EtPpsB&!d`@-fR`d@EB zw=q-Keo%|+8@s`4eu3jr8p{O}es64STj&>O+C9YE7Rd@JT`dHmz%`{+7r zaB`a#0J_-vW15zoGIX9Nu}3El zZ_tq6(f!;no^AL3XLI+!Bbh1tKz+zZuS7RqS^b9p!Nz-kST4ywa1DI5L&j=4!sq97+T+O zUa{zux-(ne-rrLpQR-n+!0y2NXVvQ7^*RT9@@xJwo;k(z#a;Az)Q8RX4%ub*rhxm3 zPN$iII%4ilTOnMwx8$1jw02KWiVU(aVR-f6K5I!0DD}Q*+hpJHGMSmzSvT{n+?h=c zOFw5#W0iQ3E2cJ~U28?pujQxT*PQsY0i@GuPa~Uy@SoicX`se?!IJFXmz z)%!t%TG#I~{G9Ug$=1vA4h@f&|co_~Xmk!U~P{Oo8TY0dC$$$Oe_^Kojtc6e~SDbH4QXQDImlW zIE_KaSLSwoY5$pDS=$oopP6mg#IXAF>kT>w40g=esaqZCurE*~`qCud_0vB6)8D^d z#OmIknyqucKY^r5aN{VZHa+bB-9MRoj&EwXAt${;{uH8J`1897r0Zawf4KZ**nu64 zvCQ=wUthg!zZJBsoI_E?Ldle2)fICluKhQz^YCrdQJ5XqUHS8dgD|Vy3)90(1qI61 zcYvpMul?p*WWX9ATOC(<{PeW@(?GR=N(5-(;f{=@q5tP@0gVWX)EgfKEgnm_Ca=)9 zHNa1K!TG#~=M$5@$O*rjkq25B4jQ~uKPSgv>g^mGdO^x#gTS0v&_&RTucvAJW=@&e zWpT}E=PzcB2g5>RX@8VYY`3|RW?>g!F@=l{F2;uY&< zju&UM5pBWKOg)BXOh0Zo_lW=ps)uam=H-7e({#CBs zal>4;bLKrrrBMf3UiBK(!2vDM>ORH9(>%N79BYA_>!slD`-;9b#V{*?JfW%h)RXvFaDg&)6}5hHaS`*j{n+!o^Y*6`ssb|Y>TkZp<#N~R19(&9_z zO=ktIwqiKVbjB(Ag8YHkpp3Oof|0AjIPldG+y52MT?#)xaR8TemnMqn9x(kmfA`y0 zoVUNK?%+7LF4VMUY3X^B^Zh&H_A2kWm~DEcCgkIq)}?V@HV6 zlGc*$`d^Rd3WF!hS_G2%Gs+oseE;RIiM|e=l&AwO!rEZ(Py?DXKd@bvaqUadzzcZ{ z30|(3CRWE={(f({ybF{rTdpiHVPi;m{jB9LW0wIF*WQh6TYfVi*)4WMzTq$9oL7In zwT!aXYv=Dje#-?ksMIncwPPRTbh|Bizw`eo$4)W)VZ6O@Dr>~t_;%1*i=$_9D^D@S z)a<%?@IBV4`To_2s$b8y)VBw;LOOE!5%uuvW*xI{)3) z3HAPmAMxDfGFq2C;ZM>1kDKi_+JH+C#U*@8%@|gJF2QTasrq_~X;tOpXl}?lf%6<3 zyTe`fNs6zC^q3SFzyHyK9vf0nK-Ua2pgdM^z4}ZE`@!$9Dxb^ZVIWw^Ci!E#if?6WP5|&*5*<1Sg_Rj(8z^uOyx;4k&m5U&Rg{M*~Qx>$LyO9WoDm=|NCa)+_|yS+P%ODnr8uH0Q+<| zhmZFoUb9wjmOijEt{XIDo)J>Gmeu|@^OiK-D>9B+T;bbSetZ0VM#byh)7ss@I*lBd z7jS|`&x#kX68BMFU=AHNy|Bub{SALY&ql!qU*EOn9Q6HWbyw5NYMyB{QDURk#=BTZgiN!*03Km zIVx~DkHL5^WK88x)sxhDkg?&b{PSja&0$=!9F$2|r>TN7iGa6DCPT=_XH2^e9A}ey z`W3unN+EyQm*31HyTo{!&YoiWqSU*1Z#;LM6rsT&Y^F2d|bpl8aJxbQoL`%@#qIqry{=S3zN z!}^!<8eih}-#Vg^fA+yGN$?7_gA?5>d7J*uh~BIGD2DslVk6M_;G(cpp*gvHYhJw- z|1*ujy{>lg`uCqoA%o*BEUs&;8N?VOc3$KDSGW4suYU}6ruUNT8xqP`M1I_GQ0Bb1 zp4I*|lhl*9O^00HGVk)c_xaOrJLY#={=9=Y#&n_-Lss2H^Wbgr(iwBEKX|+KIq3XF z_h99SFZnb7>SUf2KJ||wvbW*d8rGRgtDfIj@4x4FD0@D*a&wAuS;E$kQNHlochDxK zE(1^_4Kb#o7hTV=J=<|!s8{d5V@IshqWnPPmjX^)3g_e)R?d=7h|Rt7mvIxgeFiFS zmQ}^FOy6qXz#z}G>ha#6tMmW%c9wqvkBlg)2tB&PsBp4Lb^4)sHLsV?1>Lr$_>S$w zvg=84he2l=M#rq@n8qq{L4HBB$g!1XUsvy4Kj-hpr;6Z6pU~8Ki6Ow_`RPm3_s@&@ zu-U%BF|Jwp@8xX6_0N)-SHrX9?Mwd{HcwfQ{GPMiF1NA+5=6p_Oc+=`CEgW3T=%Xl zHnr}~idXE_$&fkoY5VLQwk>Dfaq0Fi(1>&cgTBU!`EP6!zlpFP2De}pLR*;rr+1s=VP}ApOj}zTwfcYgzRT{fu8XS8D5T=Y@0`olZH< zv1Smtca7^y9cazv>r+fUpW1dn@}lxv{sYI^UOhQ`S6+d&p!C+}!%m-`o9G2T$`InlBv+8MXKd>h}Ez0(HL_l9d^>5BQfU7tNq z2Z6fCP96*{nG6ej?>X&y@#x0-zHefmH`zCQoOrZs%XZ62Q3v1fKe)p2VOO}z{P5TO zZ+JQUZvE~r_Y*O{$_)-K6^2D73{zg!d0*J}BmdFE-c?8UOtdq*V6pz$#E=U@G#B|onCxV?@yxb{?ReDvtneeS$p@8sq)N?gj=ajV&U zhHAKQ)|Bus#^5bH;AjtCeNPZvl)7#ZlXF&Dyzk?2>-WVUra^p=u;>os298S6favf4 zBmciY_vq%o)xvjvZEC2g(tQ&9%ww(At34|>L8gK__UIfqx}qtTd(l0i6P?w*Gi)16 z|ACEJBIbFK>CVJ|YyV~M^*>tn)2G;e{=qowX79avO`9`wZBG5T-gHm!-ixhLS!Sy? zTx)veweVbYKxE?Sz3ONFDMQnMhwY-fj0;5O<;5|{mO;j5&t-r3cB}ZnKZa@GOd9=4 z^p^aAo5{?klV`b!Zx&m&;#5Q6?-=9PQ~#_4m&hJ(T(TJy4&MK=kNtFnSc1fx_r0d_ z(h)6!0e^Z+XP#zSHIYwVIwSVIk%OxPr}mQmrr{WE2Y?Ax>Mi{@_s;^xrtm+?xw*U9y_?;T*RK0oj7V%<65>RG@ghrwp& z)4%KMzRt9L#w3wbrqbIGHU)HwTwQYPr%epa;B0%Rw^SgN(U$Agr)_eUneVgH_pi4G zEs5q(Z26*k>o^0`0pI&~A15+cr=FkiabsiK!8#i)zxS;c@}lW=OX{m~vz{h1@4ckG z!#426jp|1^hhu%gq2KaF{gx~P!vckR;T1A$48NI=1c*)W&HpUR_z`rZ!jFsc4tp6U z&VOT^_)R3&5TeCI;n8shriQ8;L2@59HqLvsRen`%%Aah-4aeD}&a4LweykR?3Fq{C z8I{@!S?tpy;FKfum9N1?wS4&`rVU?pQ*ZS1o>_kV@;`=okgk4I$9nsQ`Te{hE1inA zeo;|>Q~mbs`zJGvK@BMZr!2uzeg@VPzW47Y^EH^h<-bu|&#*sx%}sgmeHEMR8-zI* z$%U=#x%K_kP5!jG(hyy<1i$hzFkb29`tJ1M?W^-|YQ10Xvv07!&u|?y#_^4P!<6eQ z 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 \ No newline at end of file diff --git a/assets/plezy_android_foreground.png b/assets/plezy_android_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..68bda7f95cf8ac389af9dcf1fe711715cfecb5fd GIT binary patch literal 60509 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 AD*ylh literal 0 HcmV?d00001 diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 000000000..fa0b357c4 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 000000000..7a7f9873a --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 000000000..1dc6cf765 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000..ec97fc6f3 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000..c4855bfe2 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 000000000..620e46eba --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 000000000..63a235f04 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,73 @@ +PODS: + - Flutter (1.0.0) + - media_kit_libs_ios_video (1.0.4): + - Flutter + - media_kit_video (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + - volume_controller (0.0.1): + - Flutter + - wakelock_plus (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) + - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - volume_controller (from `.symlinks/plugins/volume_controller/ios`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + media_kit_libs_ios_video: + :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" + media_kit_video: + :path: ".symlinks/plugins/media_kit_video/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + volume_controller: + :path: ".symlinks/plugins/volume_controller/ios" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 + media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..7c5f2cebc --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,733 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C12D873DE8572579684A82C /* Pods_Runner.framework */; }; + 6A48DFB02EA70C7100C1F7CD /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + F37BB572E0F0606E20596E43 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1E1EC7646F3085BF9127AB2A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 2C12D873DE8572579684A82C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5B81FA7048BFA976F49D555E /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 83B9EB35C25EEF40090F6A88 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 87ADF6219C65A13967DFF03C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A8738BEC00467F05948734DD /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D239CED757CED630C2B0A161 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 582EBCC1C90BEBEC365D972D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F37BB572E0F0606E20596E43 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 67716449E04A72AA71A90156 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2C12D873DE8572579684A82C /* Pods_Runner.framework */, + 3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 875C8AEFBBA3759A1849D674 /* Pods */ = { + isa = PBXGroup; + children = ( + A8738BEC00467F05948734DD /* Pods-Runner.debug.xcconfig */, + 83B9EB35C25EEF40090F6A88 /* Pods-Runner.release.xcconfig */, + 1E1EC7646F3085BF9127AB2A /* Pods-Runner.profile.xcconfig */, + 5B81FA7048BFA976F49D555E /* Pods-RunnerTests.debug.xcconfig */, + D239CED757CED630C2B0A161 /* Pods-RunnerTests.release.xcconfig */, + 87ADF6219C65A13967DFF03C /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 875C8AEFBBA3759A1849D674 /* Pods */, + 67716449E04A72AA71A90156 /* Frameworks */, + 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 684A4A968F313A7FDAEBF3B8 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 582EBCC1C90BEBEC365D972D /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 01D58DD9E289D97FD8DBE66E /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 2E10BAE564A4AFFCFF5B4EF0 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 6A48DFB02EA70C7100C1F7CD /* plezy.icon in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 01D58DD9E289D97FD8DBE66E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 2E10BAE564A4AFFCFF5B4EF0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 684A4A968F313A7FDAEBF3B8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5B81FA7048BFA976F49D555E /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D239CED757CED630C2B0A161 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 87ADF6219C65A13967DFF03C /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..f9b0d7c5e --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000..e3773d42e --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..21a3cc14c --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..f9b0d7c5e --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 000000000..626664468 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000..f2e259c7c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000..f3c28516f --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 000000000..0fc3e407d --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,60 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Plezy + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Plezy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _plexmediasvr._tcp + + NSLocalNetworkUsageDescription + This app needs to connect to your Plex Media Server on your local network. + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 000000000..308a2a560 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..86a7c3b1b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/ios/plezy.icon/Assets/plezy-cropped.svg b/ios/plezy.icon/Assets/plezy-cropped.svg new file mode 100644 index 000000000..d34219dd4 --- /dev/null +++ b/ios/plezy.icon/Assets/plezy-cropped.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ios/plezy.icon/icon.json b/ios/plezy.icon/icon.json new file mode 100644 index 000000000..3af70ecd8 --- /dev/null +++ b/ios/plezy.icon/icon.json @@ -0,0 +1,55 @@ +{ + "fill" : { + "solid" : "gray:0.94630,1.00000" + }, + "groups" : [ + { + "blur-material" : null, + "layers" : [ + { + "blend-mode-specializations" : [ + { + "appearance" : "dark", + "value" : "normal" + } + ], + "fill-specializations" : [ + { + "value" : "none" + }, + { + "appearance" : "dark", + "value" : "none" + } + ], + "glass" : true, + "hidden" : false, + "image-name" : "plezy-cropped.svg", + "name" : "plezy-cropped", + "position" : { + "scale" : 2, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/lib/auth/plex_auth.dart b/lib/auth/plex_auth.dart new file mode 100644 index 000000000..b8cfb5985 --- /dev/null +++ b/lib/auth/plex_auth.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class PlexAuth { + static const String authUrl = 'https://plex.tv/api/v2'; + static const String clientsUrl = 'https://clients.plex.tv/api/v2'; + + final String clientIdentifier; + final String product; + + PlexAuth({ + required this.clientIdentifier, + this.product = 'Plex Flutter Client', + }); + + Map get _headers => { + 'Accept': 'application/json', + 'X-Plex-Client-Identifier': clientIdentifier, + 'X-Plex-Product': product, + }; + + /// Generate a PIN for authentication + Future> generatePin({bool strong = true}) async { + final response = await http.post( + Uri.parse('$authUrl/pins?strong=$strong'), + headers: _headers, + ); + + if (response.statusCode == 201) { + return json.decode(response.body); + } else { + throw Exception('Failed to generate PIN: ${response.body}'); + } + } + + /// Check PIN status + Future> checkPin(int pinId) async { + final response = await http.get( + Uri.parse('$authUrl/pins/$pinId'), + headers: _headers, + ); + + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + throw Exception('Failed to check PIN: ${response.body}'); + } + } + + /// Get auth app URL for user to authenticate + String getAuthAppUrl(String code, {String? forwardUrl}) { + final params = { + 'clientID': clientIdentifier, + 'code': code, + 'context[device][product]': product, + if (forwardUrl != null) 'forwardUrl': forwardUrl, + }; + + final queryString = params.entries + .map( + (e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', + ) + .join('&'); + + return 'https://app.plex.tv/auth#?$queryString'; + } + + /// Verify token validity + Future verifyToken(String token) async { + try { + final response = await http.get( + Uri.parse('$authUrl/user'), + headers: {..._headers, 'X-Plex-Token': token}, + ); + return response.statusCode == 200; + } catch (e) { + return false; + } + } + + /// Get user info + Future> getUserInfo(String token) async { + final response = await http.get( + Uri.parse('$authUrl/user'), + headers: {..._headers, 'X-Plex-Token': token}, + ); + + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + throw Exception('Failed to get user info: ${response.body}'); + } + } + + /// Get available resources (servers) + Future> getResources(String token) async { + final response = await http.get( + Uri.parse( + '$clientsUrl/resources?includeHttps=1&includeRelay=1&includeIPv6=1', + ), + headers: {..._headers, 'X-Plex-Token': token}, + ); + + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + throw Exception('Failed to get resources: ${response.body}'); + } + } +} diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart new file mode 100644 index 000000000..0594a426b --- /dev/null +++ b/lib/client/plex_client.dart @@ -0,0 +1,663 @@ +import 'package:dio/dio.dart'; +import '../config/plex_config.dart'; +import '../models/plex_library.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_media_info.dart'; +import '../models/plex_filter.dart'; +import '../utils/app_logger.dart'; + +class PlexClient { + final PlexConfig config; + late final Dio _dio; + + PlexClient(this.config) { + _dio = Dio( + BaseOptions( + baseUrl: config.baseUrl, + headers: config.headers, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + validateStatus: (status) => status != null && status < 500, + ), + ); + + // Add interceptor for logging (optional, can be disabled in production) + _dio.interceptors.add( + LogInterceptor( + requestBody: false, + responseBody: false, + error: true, + requestHeader: false, + responseHeader: false, + ), + ); + } + + /// Test connection to server + Future testConnection() async { + try { + final response = await _dio.get('/'); + return response.statusCode == 200 || response.statusCode == 401; + } catch (e) { + return false; + } + } + + /// Test connection to a specific URL with token + static Future testConnectionUrl( + String baseUrl, + String token, { + Duration timeout = const Duration(seconds: 5), + }) async { + try { + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: timeout, + receiveTimeout: timeout, + validateStatus: (status) => status != null && status < 500, + ), + ); + + final response = await dio.get( + '/', + options: Options(headers: {'X-Plex-Token': token}), + ); + + return response.statusCode == 200 || response.statusCode == 401; + } catch (e) { + return false; + } + } + + /// Get server identity + Future> getServerIdentity() async { + final response = await _dio.get('/identity'); + return response.data; + } + + /// Get library sections + Future> getLibraries() async { + final response = await _dio.get('/library/sections'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Directory'] != null) { + return (container['Directory'] as List) + .map((json) => PlexLibrary.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get library content by section ID + Future> getLibraryContent( + String sectionId, { + int? start, + int? size, + Map? filters, + }) async { + final queryParams = {}; + if (start != null) queryParams['X-Plex-Container-Start'] = start; + if (size != null) queryParams['X-Plex-Container-Size'] = size; + + // Add filter parameters + if (filters != null) { + queryParams.addAll(filters); + } + + final response = await _dio.get( + '/library/sections/$sectionId/all', + queryParameters: queryParams, + ); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get metadata by rating key + Future getMetadata(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + return PlexMetadata.fromJson(container['Metadata'][0]); + } + } + + return null; + } + + /// Get metadata by rating key with images (includes clearLogo and OnDeck) + Future> getMetadataWithImagesAndOnDeck( + String ratingKey, + ) async { + final response = await _dio.get( + '/library/metadata/$ratingKey', + queryParameters: {'includeOnDeck': 1}, + ); + + PlexMetadata? metadata; + PlexMetadata? onDeckEpisode; + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + + // Get main metadata + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + final metadataJson = container['Metadata'][0]; + metadata = PlexMetadata.fromJsonWithImages(metadataJson); + + // Check if OnDeck is nested inside Metadata + if (metadataJson.containsKey('OnDeck') && + metadataJson['OnDeck'] != null) { + final onDeckData = metadataJson['OnDeck']; + + // OnDeck can be either a Map with 'Metadata' key or direct metadata + if (onDeckData is Map && onDeckData.containsKey('Metadata')) { + final onDeckMetadata = onDeckData['Metadata']; + if (onDeckMetadata != null) { + onDeckEpisode = PlexMetadata.fromJson(onDeckMetadata); + } + } + } + } + } + + return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode}; + } + + /// Get metadata by rating key with images (includes clearLogo) + Future getMetadataWithImages(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + return PlexMetadata.fromJsonWithImages(container['Metadata'][0]); + } + } + + return null; + } + + /// Search across all libraries using the hub search endpoint + /// Only returns movies and shows, filtering out seasons and episodes + Future> search(String query, {int limit = 10}) async { + final response = await _dio.get( + '/hubs/search', + queryParameters: { + 'query': query, + 'limit': limit, + 'includeCollections': 1, + }, + ); + + final results = []; + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Hub'] != null) { + // Each hub contains results of a specific type (movies, shows, etc.) + for (final hub in container['Hub'] as List) { + final hubType = hub['type'] as String?; + + // Only include movie and show hubs + if (hubType != 'movie' && hubType != 'show') { + continue; + } + + // Hubs can contain either Metadata (for movies) or Directory (for shows) + if (hub['Metadata'] != null) { + for (final json in hub['Metadata'] as List) { + try { + results.add(PlexMetadata.fromJson(json)); + } catch (e) { + // Skip items that fail to parse + appLogger.w('Failed to parse search result', error: e); + appLogger.d('Problematic JSON: $json'); + } + } + } + if (hub['Directory'] != null) { + for (final json in hub['Directory'] as List) { + try { + results.add(PlexMetadata.fromJson(json)); + } catch (e) { + // Skip items that fail to parse + appLogger.w('Failed to parse search result', error: e); + appLogger.d('Problematic JSON: $json'); + } + } + } + } + } + } + + return results; + } + + /// Get recently added media + Future> getRecentlyAdded({int limit = 50}) async { + final response = await _dio.get( + '/library/recentlyAdded', + queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, + ); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get on deck items (continue watching) + Future> getOnDeck() async { + final response = await _dio.get('/library/onDeck'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJsonWithImages(json)) + .toList(); + } + } + + return []; + } + + /// Get children of a metadata item (e.g., seasons for a show, episodes for a season) + Future> getChildren(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey/children'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get thumbnail URL + String getThumbnailUrl(String? thumbPath) { + if (thumbPath == null || thumbPath.isEmpty) return ''; + + // Remove leading slash if present + final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath; + + return '${config.baseUrl}/$path?X-Plex-Token=${config.token}'; + } + + /// Get video URL for direct playback + Future getVideoUrl(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + final metadata = container['Metadata'][0]; + + // Get the first Media item and its Part + if (metadata['Media'] != null && + (metadata['Media'] as List).isNotEmpty) { + final media = metadata['Media'][0]; + if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { + final part = media['Part'][0]; + final partKey = part['key'] as String?; + + if (partKey != null) { + // Return direct play URL + return '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; + } + } + } + } + } + + return null; + } + + /// Get chapters for a media item + Future> getChapters(String ratingKey) async { + final response = await _dio.get( + '/library/metadata/$ratingKey', + queryParameters: {'includeChapters': 1}, + ); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + final metadata = container['Metadata'][0]; + + if (metadata['Chapter'] != null) { + final chapterList = metadata['Chapter'] as List; + return chapterList.map((chapter) { + return PlexChapter( + id: chapter['id'] as int, + index: chapter['index'] as int?, + startTimeOffset: chapter['startTimeOffset'] as int?, + endTimeOffset: chapter['endTimeOffset'] as int?, + title: chapter['tag'] as String?, + thumb: chapter['thumb'] as String?, + ); + }).toList(); + } + } + } + + return []; + } + + /// Get detailed media info including chapters and tracks + Future getMediaInfo(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + final metadata = container['Metadata'][0]; + + // Get the first Media item and its Part + if (metadata['Media'] != null && + (metadata['Media'] as List).isNotEmpty) { + final media = metadata['Media'][0]; + if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { + final part = media['Part'][0]; + final partKey = part['key'] as String?; + + if (partKey != null) { + // Parse streams (audio and subtitle tracks) + final streams = part['Stream'] as List? ?? []; + final audioTracks = []; + final subtitleTracks = []; + + for (var stream in streams) { + final streamType = stream['streamType'] as int?; + + if (streamType == 2) { + // Audio track + audioTracks.add( + PlexAudioTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + channels: stream['channels'] as int?, + selected: stream['selected'] == 1, + ), + ); + } else if (streamType == 3) { + // Subtitle track + subtitleTracks.add( + PlexSubtitleTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + selected: stream['selected'] == 1, + forced: stream['forced'] == 1, + key: stream['key'] as String?, + ), + ); + } + } + + // Parse chapters + final chapters = []; + if (metadata['Chapter'] != null) { + final chapterList = metadata['Chapter'] as List; + for (var chapter in chapterList) { + chapters.add( + PlexChapter( + id: chapter['id'] as int, + index: chapter['index'] as int?, + startTimeOffset: chapter['startTimeOffset'] as int?, + endTimeOffset: chapter['endTimeOffset'] as int?, + title: chapter['title'] as String?, + thumb: chapter['thumb'] as String?, + ), + ); + } + } + + return PlexMediaInfo( + videoUrl: + '${config.baseUrl}$partKey?X-Plex-Token=${config.token}', + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: chapters, + ); + } + } + } + } + } + + return null; + } + + /// Mark media as watched + Future markAsWatched(String ratingKey) async { + await _dio.get( + '/:/scrobble', + queryParameters: { + 'key': ratingKey, + 'identifier': 'com.plexapp.plugins.library', + }, + ); + } + + /// Mark media as unwatched + Future markAsUnwatched(String ratingKey) async { + await _dio.get( + '/:/unscrobble', + queryParameters: { + 'key': ratingKey, + 'identifier': 'com.plexapp.plugins.library', + }, + ); + } + + /// Update playback progress + Future updateProgress( + String ratingKey, { + required int time, + required String state, // 'playing', 'paused', 'stopped', 'buffering' + int? duration, + }) async { + await _dio.post( + '/:/timeline', + queryParameters: { + 'ratingKey': ratingKey, + 'key': '/library/metadata/$ratingKey', + 'time': time, + 'state': state, + if (duration != null) 'duration': duration, + }, + ); + } + + /// Get server preferences + Future> getServerPreferences() async { + final response = await _dio.get('/:/prefs'); + return response.data; + } + + /// Get sessions (currently playing) + Future> getSessions() async { + final response = await _dio.get('/status/sessions'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return container['Metadata'] as List; + } + } + + return []; + } + + /// Get available filters for a library section + Future> getLibraryFilters(String sectionId) async { + final response = await _dio.get('/library/sections/$sectionId/filters'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Directory'] != null) { + return (container['Directory'] as List) + .map((json) => PlexFilter.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get filter values (e.g., list of genres, years, etc.) + Future> getFilterValues(String filterKey) async { + final response = await _dio.get(filterKey); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Directory'] != null) { + return (container['Directory'] as List) + .map((json) => PlexFilterValue.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get next episode for a TV show episode + Future getNextEpisode(PlexMetadata currentEpisode) async { + if (currentEpisode.type.toLowerCase() != 'episode') { + return null; + } + + final parentKey = currentEpisode.parentRatingKey; + final grandparentKey = currentEpisode.grandparentRatingKey; + + if (parentKey == null || grandparentKey == null) { + return null; + } + + try { + // Get all episodes in the current season + final episodes = await getChildren(parentKey); + + // Find the current episode index + final currentIndex = episodes.indexWhere( + (e) => e.ratingKey == currentEpisode.ratingKey, + ); + + if (currentIndex != -1 && currentIndex < episodes.length - 1) { + // Return next episode in the same season + return episodes[currentIndex + 1]; + } else if (currentIndex == episodes.length - 1) { + // Last episode of the season, try to get first episode of next season + final seasons = await getChildren(grandparentKey); + final currentSeasonIndex = seasons.indexWhere( + (s) => s.ratingKey == parentKey, + ); + + if (currentSeasonIndex != -1 && + currentSeasonIndex < seasons.length - 1) { + final nextSeason = seasons[currentSeasonIndex + 1]; + final nextSeasonEpisodes = await getChildren(nextSeason.ratingKey); + + if (nextSeasonEpisodes.isNotEmpty) { + return nextSeasonEpisodes.first; + } + } + } + } catch (e) { + // Silently handle errors + } + + return null; + } + + /// Get previous episode for a TV show episode + Future getPreviousEpisode(PlexMetadata currentEpisode) async { + if (currentEpisode.type.toLowerCase() != 'episode') { + return null; + } + + final parentKey = currentEpisode.parentRatingKey; + final grandparentKey = currentEpisode.grandparentRatingKey; + + if (parentKey == null || grandparentKey == null) { + return null; + } + + try { + // Get all episodes in the current season + final episodes = await getChildren(parentKey); + + // Find the current episode index + final currentIndex = episodes.indexWhere( + (e) => e.ratingKey == currentEpisode.ratingKey, + ); + + if (currentIndex > 0) { + // Return previous episode in the same season + return episodes[currentIndex - 1]; + } else if (currentIndex == 0) { + // First episode of the season, try to get last episode of previous season + final seasons = await getChildren(grandparentKey); + final currentSeasonIndex = seasons.indexWhere( + (s) => s.ratingKey == parentKey, + ); + + if (currentSeasonIndex > 0) { + final previousSeason = seasons[currentSeasonIndex - 1]; + final previousSeasonEpisodes = await getChildren( + previousSeason.ratingKey, + ); + + if (previousSeasonEpisodes.isNotEmpty) { + return previousSeasonEpisodes.last; + } + } + } + } catch (e) { + // Silently handle errors + } + + return null; + } +} diff --git a/lib/config/plex_config.dart b/lib/config/plex_config.dart new file mode 100644 index 000000000..91babd2e0 --- /dev/null +++ b/lib/config/plex_config.dart @@ -0,0 +1,60 @@ +class PlexConfig { + final String baseUrl; + final String? token; + final String clientIdentifier; + final String product; + final String version; + final String platform; + final String? device; + final bool acceptJson; + + PlexConfig({ + required this.baseUrl, + this.token, + required this.clientIdentifier, + this.product = 'Plezy', + this.version = '1.0.0', + this.platform = 'Flutter', + this.device, + this.acceptJson = true, + }); + + Map get headers { + final headers = { + 'X-Plex-Client-Identifier': clientIdentifier, + 'X-Plex-Product': product, + 'X-Plex-Version': version, + 'X-Plex-Platform': platform, + if (device != null) 'X-Plex-Device': device!, + if (acceptJson) 'Accept': 'application/json', + }; + + if (token != null) { + headers['X-Plex-Token'] = token!; + } + + return headers; + } + + PlexConfig copyWith({ + String? baseUrl, + String? token, + String? clientIdentifier, + String? product, + String? version, + String? platform, + String? device, + bool? acceptJson, + }) { + return PlexConfig( + baseUrl: baseUrl ?? this.baseUrl, + token: token ?? this.token, + clientIdentifier: clientIdentifier ?? this.clientIdentifier, + product: product ?? this.product, + version: version ?? this.version, + platform: platform ?? this.platform, + device: device ?? this.device, + acceptJson: acceptJson ?? this.acceptJson, + ); + } +} diff --git a/lib/data/iso_639_codes.json b/lib/data/iso_639_codes.json new file mode 100644 index 000000000..7a74142f6 --- /dev/null +++ b/lib/data/iso_639_codes.json @@ -0,0 +1 @@ +{"aa":{"639-1":"aa","639-2":"aar","family":"Afro-Asiatic","name":"Afar","nativeName":"Afaraf","wikiUrl":"https://en.wikipedia.org/wiki/Afar_language"},"ab":{"639-1":"ab","639-2":"abk","family":"Northwest Caucasian","name":"Abkhaz","nativeName":"аҧсуа бызшәа, аҧсшәа","wikiUrl":"https://en.wikipedia.org/wiki/Abkhaz_language"},"ae":{"639-1":"ae","639-2":"ave","family":"Indo-European","name":"Avestan","nativeName":"avesta","wikiUrl":"https://en.wikipedia.org/wiki/Avestan_language"},"af":{"639-1":"af","639-2":"afr","family":"Indo-European","name":"Afrikaans","nativeName":"Afrikaans","wikiUrl":"https://en.wikipedia.org/wiki/Afrikaans_language"},"ak":{"639-1":"ak","639-2":"aka","family":"Niger–Congo","name":"Akan","nativeName":"Akan","wikiUrl":"https://en.wikipedia.org/wiki/Akan_language"},"am":{"639-1":"am","639-2":"amh","family":"Afro-Asiatic","name":"Amharic","nativeName":"አማርኛ","wikiUrl":"https://en.wikipedia.org/wiki/Amharic_language"},"an":{"639-1":"an","639-2":"arg","family":"Indo-European","name":"Aragonese","nativeName":"aragonés","wikiUrl":"https://en.wikipedia.org/wiki/Aragonese_language"},"ar":{"639-1":"ar","639-2":"ara","family":"Afro-Asiatic","name":"Arabic","nativeName":"العربية","wikiUrl":"https://en.wikipedia.org/wiki/Arabic_language"},"as":{"639-1":"as","639-2":"asm","family":"Indo-European","name":"Assamese","nativeName":"অসমীয়া","wikiUrl":"https://en.wikipedia.org/wiki/Assamese_language"},"av":{"639-1":"av","639-2":"ava","family":"Northeast Caucasian","name":"Avaric","nativeName":"авар мацӀ, магӀарул мацӀ","wikiUrl":"https://en.wikipedia.org/wiki/Avar_language"},"ay":{"639-1":"ay","639-2":"aym","family":"Aymaran","name":"Aymara","nativeName":"aymar aru","wikiUrl":"https://en.wikipedia.org/wiki/Aymara_language"},"az":{"639-1":"az","639-2":"aze","family":"Turkic","name":"Azerbaijani","nativeName":"azərbaycan dili","wikiUrl":"https://en.wikipedia.org/wiki/Azerbaijani_language"},"ba":{"639-1":"ba","639-2":"bak","family":"Turkic","name":"Bashkir","nativeName":"башҡорт теле","wikiUrl":"https://en.wikipedia.org/wiki/Bashkir_language"},"be":{"639-1":"be","639-2":"bel","family":"Indo-European","name":"Belarusian","nativeName":"беларуская мова","wikiUrl":"https://en.wikipedia.org/wiki/Belarusian_language"},"bg":{"639-1":"bg","639-2":"bul","family":"Indo-European","name":"Bulgarian","nativeName":"български език","wikiUrl":"https://en.wikipedia.org/wiki/Bulgarian_language"},"bh":{"639-1":"bh","639-2":"bih","family":"Indo-European","name":"Bihari","nativeName":"भोजपुरी","wikiUrl":"https://en.wikipedia.org/wiki/Bihari_languages"},"bi":{"639-1":"bi","639-2":"bis","family":"Creole","name":"Bislama","nativeName":"Bislama","wikiUrl":"https://en.wikipedia.org/wiki/Bislama_language"},"bm":{"639-1":"bm","639-2":"bam","family":"Niger–Congo","name":"Bambara","nativeName":"bamanankan","wikiUrl":"https://en.wikipedia.org/wiki/Bambara_language"},"bn":{"639-1":"bn","639-2":"ben","family":"Indo-European","name":"Bengali, Bangla","nativeName":"বাংলা","wikiUrl":"https://en.wikipedia.org/wiki/Bengali_language"},"bo":{"639-1":"bo","639-2":"bod","639-2/B":"tib","family":"Sino-Tibetan","name":"Tibetan Standard, Tibetan, Central","nativeName":"བོད་ཡིག","wikiUrl":"https://en.wikipedia.org/wiki/Standard_Tibetan"},"br":{"639-1":"br","639-2":"bre","family":"Indo-European","name":"Breton","nativeName":"brezhoneg","wikiUrl":"https://en.wikipedia.org/wiki/Breton_language"},"bs":{"639-1":"bs","639-2":"bos","family":"Indo-European","name":"Bosnian","nativeName":"bosanski jezik","wikiUrl":"https://en.wikipedia.org/wiki/Bosnian_language"},"ca":{"639-1":"ca","639-2":"cat","family":"Indo-European","name":"Catalan","nativeName":"català","wikiUrl":"https://en.wikipedia.org/wiki/Catalan_language"},"ce":{"639-1":"ce","639-2":"che","family":"Northeast Caucasian","name":"Chechen","nativeName":"нохчийн мотт","wikiUrl":"https://en.wikipedia.org/wiki/Chechen_language"},"ch":{"639-1":"ch","639-2":"cha","family":"Austronesian","name":"Chamorro","nativeName":"Chamoru","wikiUrl":"https://en.wikipedia.org/wiki/Chamorro_language"},"co":{"639-1":"co","639-2":"cos","family":"Indo-European","name":"Corsican","nativeName":"corsu, lingua corsa","wikiUrl":"https://en.wikipedia.org/wiki/Corsican_language"},"cr":{"639-1":"cr","639-2":"cre","family":"Algonquian","name":"Cree","nativeName":"ᓀᐦᐃᔭᐍᐏᐣ","wikiUrl":"https://en.wikipedia.org/wiki/Cree_language"},"cs":{"639-1":"cs","639-2":"ces","639-2/B":"cze","family":"Indo-European","name":"Czech","nativeName":"čeština, český jazyk","wikiUrl":"https://en.wikipedia.org/wiki/Czech_language"},"cu":{"639-1":"cu","639-2":"chu","family":"Indo-European","name":"Old Church Slavonic, Church Slavonic, Old Bulgarian","nativeName":"ѩзыкъ словѣньскъ","wikiUrl":"https://en.wikipedia.org/wiki/Old_Church_Slavonic"},"cv":{"639-1":"cv","639-2":"chv","family":"Turkic","name":"Chuvash","nativeName":"чӑваш чӗлхи","wikiUrl":"https://en.wikipedia.org/wiki/Chuvash_language"},"cy":{"639-1":"cy","639-2":"cym","639-2/B":"wel","family":"Indo-European","name":"Welsh","nativeName":"Cymraeg","wikiUrl":"https://en.wikipedia.org/wiki/Welsh_language"},"da":{"639-1":"da","639-2":"dan","family":"Indo-European","name":"Danish","nativeName":"dansk","wikiUrl":"https://en.wikipedia.org/wiki/Danish_language"},"de":{"639-1":"de","639-2":"deu","639-2/B":"ger","family":"Indo-European","name":"German","nativeName":"Deutsch","wikiUrl":"https://en.wikipedia.org/wiki/German_language"},"dv":{"639-1":"dv","639-2":"div","family":"Indo-European","name":"Divehi, Dhivehi, Maldivian","nativeName":"ދިވެހި","wikiUrl":"https://en.wikipedia.org/wiki/Dhivehi_language"},"dz":{"639-1":"dz","639-2":"dzo","family":"Sino-Tibetan","name":"Dzongkha","nativeName":"རྫོང་ཁ","wikiUrl":"https://en.wikipedia.org/wiki/Dzongkha_language"},"ee":{"639-1":"ee","639-2":"ewe","family":"Niger–Congo","name":"Ewe","nativeName":"Eʋegbe","wikiUrl":"https://en.wikipedia.org/wiki/Ewe_language"},"el":{"639-1":"el","639-2":"ell","639-2/B":"gre","family":"Indo-European","name":"Greek (modern)","nativeName":"ελληνικά","wikiUrl":"https://en.wikipedia.org/wiki/Greek_language"},"en":{"639-1":"en","639-2":"eng","family":"Indo-European","name":"English","nativeName":"English","wikiUrl":"https://en.wikipedia.org/wiki/English_language"},"eo":{"639-1":"eo","639-2":"epo","family":"Constructed","name":"Esperanto","nativeName":"Esperanto","wikiUrl":"https://en.wikipedia.org/wiki/Esperanto"},"es":{"639-1":"es","639-2":"spa","family":"Indo-European","name":"Spanish","nativeName":"Español","wikiUrl":"https://en.wikipedia.org/wiki/Spanish_language"},"et":{"639-1":"et","639-2":"est","family":"Uralic","name":"Estonian","nativeName":"eesti, eesti keel","wikiUrl":"https://en.wikipedia.org/wiki/Estonian_language"},"eu":{"639-1":"eu","639-2":"eus","639-2/B":"baq","family":"Language isolate","name":"Basque","nativeName":"euskara, euskera","wikiUrl":"https://en.wikipedia.org/wiki/Basque_language"},"fa":{"639-1":"fa","639-2":"fas","639-2/B":"per","family":"Indo-European","name":"Persian (Farsi)","nativeName":"فارسی","wikiUrl":"https://en.wikipedia.org/wiki/Persian_language"},"ff":{"639-1":"ff","639-2":"ful","family":"Niger–Congo","name":"Fula, Fulah, Pulaar, Pular","nativeName":"Fulfulde, Pulaar, Pular","wikiUrl":"https://en.wikipedia.org/wiki/Fula_language"},"fi":{"639-1":"fi","639-2":"fin","family":"Uralic","name":"Finnish","nativeName":"suomi, suomen kieli","wikiUrl":"https://en.wikipedia.org/wiki/Finnish_language"},"fj":{"639-1":"fj","639-2":"fij","family":"Austronesian","name":"Fijian","nativeName":"vosa Vakaviti","wikiUrl":"https://en.wikipedia.org/wiki/Fijian_language"},"fo":{"639-1":"fo","639-2":"fao","family":"Indo-European","name":"Faroese","nativeName":"føroyskt","wikiUrl":"https://en.wikipedia.org/wiki/Faroese_language"},"fr":{"639-1":"fr","639-2":"fra","639-2/B":"fre","family":"Indo-European","name":"French","nativeName":"français, langue française","wikiUrl":"https://en.wikipedia.org/wiki/French_language"},"fy":{"639-1":"fy","639-2":"fry","family":"Indo-European","name":"Western Frisian","nativeName":"Frysk","wikiUrl":"https://en.wikipedia.org/wiki/West_Frisian_language"},"ga":{"639-1":"ga","639-2":"gle","family":"Indo-European","name":"Irish","nativeName":"Gaeilge","wikiUrl":"https://en.wikipedia.org/wiki/Irish_language"},"gd":{"639-1":"gd","639-2":"gla","family":"Indo-European","name":"Scottish Gaelic, Gaelic","nativeName":"Gàidhlig","wikiUrl":"https://en.wikipedia.org/wiki/Scottish_Gaelic_language"},"gl":{"639-1":"gl","639-2":"glg","family":"Indo-European","name":"Galician","nativeName":"galego","wikiUrl":"https://en.wikipedia.org/wiki/Galician_language"},"gn":{"639-1":"gn","639-2":"grn","family":"Tupian","name":"Guaraní","nativeName":"Avañe'ẽ","wikiUrl":"https://en.wikipedia.org/wiki/Guaran%C3%AD_language"},"gu":{"639-1":"gu","639-2":"guj","family":"Indo-European","name":"Gujarati","nativeName":"ગુજરાતી","wikiUrl":"https://en.wikipedia.org/wiki/Gujarati_language"},"gv":{"639-1":"gv","639-2":"glv","family":"Indo-European","name":"Manx","nativeName":"Gaelg, Gailck","wikiUrl":"https://en.wikipedia.org/wiki/Manx_language"},"ha":{"639-1":"ha","639-2":"hau","family":"Afro-Asiatic","name":"Hausa","nativeName":"(Hausa) هَوُسَ","wikiUrl":"https://en.wikipedia.org/wiki/Hausa_language"},"he":{"639-1":"he","639-2":"heb","family":"Afro-Asiatic","name":"Hebrew (modern)","nativeName":"עברית","wikiUrl":"https://en.wikipedia.org/wiki/Hebrew_language"},"hi":{"639-1":"hi","639-2":"hin","family":"Indo-European","name":"Hindi","nativeName":"हिन्दी, हिंदी","wikiUrl":"https://en.wikipedia.org/wiki/Hindi"},"ho":{"639-1":"ho","639-2":"hmo","family":"Austronesian","name":"Hiri Motu","nativeName":"Hiri Motu","wikiUrl":"https://en.wikipedia.org/wiki/Hiri_Motu_language"},"hr":{"639-1":"hr","639-2":"hrv","family":"Indo-European","name":"Croatian","nativeName":"hrvatski jezik","wikiUrl":"https://en.wikipedia.org/wiki/Croatian_language"},"ht":{"639-1":"ht","639-2":"hat","family":"Creole","name":"Haitian, Haitian Creole","nativeName":"Kreyòl ayisyen","wikiUrl":"https://en.wikipedia.org/wiki/Haitian_Creole_language"},"hu":{"639-1":"hu","639-2":"hun","family":"Uralic","name":"Hungarian","nativeName":"magyar","wikiUrl":"https://en.wikipedia.org/wiki/Hungarian_language"},"hy":{"639-1":"hy","639-2":"hye","639-2/B":"arm","family":"Indo-European","name":"Armenian","nativeName":"Հայերեն","wikiUrl":"https://en.wikipedia.org/wiki/Armenian_language"},"hz":{"639-1":"hz","639-2":"her","family":"Niger–Congo","name":"Herero","nativeName":"Otjiherero","wikiUrl":"https://en.wikipedia.org/wiki/Herero_language"},"ia":{"639-1":"ia","639-2":"ina","family":"Constructed","name":"Interlingua","nativeName":"Interlingua","wikiUrl":"https://en.wikipedia.org/wiki/Interlingua"},"id":{"639-1":"id","639-2":"ind","family":"Austronesian","name":"Indonesian","nativeName":"Bahasa Indonesia","wikiUrl":"https://en.wikipedia.org/wiki/Indonesian_language"},"ie":{"639-1":"ie","639-2":"ile","family":"Constructed","name":"Interlingue","nativeName":"Originally called Occidental; then Interlingue after WWII","wikiUrl":"https://en.wikipedia.org/wiki/Interlingue_language"},"ig":{"639-1":"ig","639-2":"ibo","family":"Niger–Congo","name":"Igbo","nativeName":"Asụsụ Igbo","wikiUrl":"https://en.wikipedia.org/wiki/Igbo_language"},"ii":{"639-1":"ii","639-2":"iii","family":"Sino-Tibetan","name":"Nuosu","nativeName":"ꆈꌠ꒿ Nuosuhxop","wikiUrl":"https://en.wikipedia.org/wiki/Nuosu_language"},"ik":{"639-1":"ik","639-2":"ipk","family":"Eskimo–Aleut","name":"Inupiaq","nativeName":"Iñupiaq, Iñupiatun","wikiUrl":"https://en.wikipedia.org/wiki/Inupiaq_language"},"io":{"639-1":"io","639-2":"ido","family":"Constructed","name":"Ido","nativeName":"Ido","wikiUrl":"https://en.wikipedia.org/wiki/Ido_(language)"},"is":{"639-1":"is","639-2":"isl","639-2/B":"ice","family":"Indo-European","name":"Icelandic","nativeName":"Íslenska","wikiUrl":"https://en.wikipedia.org/wiki/Icelandic_language"},"it":{"639-1":"it","639-2":"ita","family":"Indo-European","name":"Italian","nativeName":"Italiano","wikiUrl":"https://en.wikipedia.org/wiki/Italian_language"},"iu":{"639-1":"iu","639-2":"iku","family":"Eskimo–Aleut","name":"Inuktitut","nativeName":"ᐃᓄᒃᑎᑐᑦ","wikiUrl":"https://en.wikipedia.org/wiki/Inuktitut"},"ja":{"639-1":"ja","639-2":"jpn","family":"Japonic","name":"Japanese","nativeName":"日本語 (にほんご)","wikiUrl":"https://en.wikipedia.org/wiki/Japanese_language"},"jv":{"639-1":"jv","639-2":"jav","family":"Austronesian","name":"Javanese","nativeName":"ꦧꦱꦗꦮ, Basa Jawa","wikiUrl":"https://en.wikipedia.org/wiki/Javanese_language"},"ka":{"639-1":"ka","639-2":"kat","639-2/B":"geo","family":"South Caucasian","name":"Georgian","nativeName":"ქართული","wikiUrl":"https://en.wikipedia.org/wiki/Georgian_language"},"kg":{"639-1":"kg","639-2":"kon","family":"Niger–Congo","name":"Kongo","nativeName":"Kikongo","wikiUrl":"https://en.wikipedia.org/wiki/Kongo_language"},"ki":{"639-1":"ki","639-2":"kik","family":"Niger–Congo","name":"Kikuyu, Gikuyu","nativeName":"Gĩkũyũ","wikiUrl":"https://en.wikipedia.org/wiki/Gikuyu_language"},"kj":{"639-1":"kj","639-2":"kua","family":"Niger–Congo","name":"Kwanyama, Kuanyama","nativeName":"Kuanyama","wikiUrl":"https://en.wikipedia.org/wiki/Kwanyama"},"kk":{"639-1":"kk","639-2":"kaz","family":"Turkic","name":"Kazakh","nativeName":"қазақ тілі","wikiUrl":"https://en.wikipedia.org/wiki/Kazakh_language"},"kl":{"639-1":"kl","639-2":"kal","family":"Eskimo–Aleut","name":"Kalaallisut, Greenlandic","nativeName":"kalaallisut, kalaallit oqaasii","wikiUrl":"https://en.wikipedia.org/wiki/Kalaallisut_language"},"km":{"639-1":"km","639-2":"khm","family":"Austroasiatic","name":"Khmer","nativeName":"ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ","wikiUrl":"https://en.wikipedia.org/wiki/Khmer_language"},"kn":{"639-1":"kn","639-2":"kan","family":"Dravidian","name":"Kannada","nativeName":"ಕನ್ನಡ","wikiUrl":"https://en.wikipedia.org/wiki/Kannada_language"},"ko":{"639-1":"ko","639-2":"kor","family":"Koreanic","name":"Korean","nativeName":"한국어","wikiUrl":"https://en.wikipedia.org/wiki/Korean_language"},"kr":{"639-1":"kr","639-2":"kau","family":"Nilo-Saharan","name":"Kanuri","nativeName":"Kanuri","wikiUrl":"https://en.wikipedia.org/wiki/Kanuri_language"},"ks":{"639-1":"ks","639-2":"kas","family":"Indo-European","name":"Kashmiri","nativeName":"कश्मीरी, كشميري‎","wikiUrl":"https://en.wikipedia.org/wiki/Kashmiri_language"},"ku":{"639-1":"ku","639-2":"kur","family":"Indo-European","name":"Kurdish","nativeName":"Kurdî, كوردی‎","wikiUrl":"https://en.wikipedia.org/wiki/Kurdish_language"},"kv":{"639-1":"kv","639-2":"kom","family":"Uralic","name":"Komi","nativeName":"коми кыв","wikiUrl":"https://en.wikipedia.org/wiki/Komi_language"},"kw":{"639-1":"kw","639-2":"cor","family":"Indo-European","name":"Cornish","nativeName":"Kernewek","wikiUrl":"https://en.wikipedia.org/wiki/Cornish_language"},"ky":{"639-1":"ky","639-2":"kir","family":"Turkic","name":"Kyrgyz","nativeName":"Кыргызча, Кыргыз тили","wikiUrl":"https://en.wikipedia.org/wiki/Kyrgyz_language"},"la":{"639-1":"la","639-2":"lat","family":"Indo-European","name":"Latin","nativeName":"latine, lingua latina","wikiUrl":"https://en.wikipedia.org/wiki/Latin"},"lb":{"639-1":"lb","639-2":"ltz","family":"Indo-European","name":"Luxembourgish, Letzeburgesch","nativeName":"Lëtzebuergesch","wikiUrl":"https://en.wikipedia.org/wiki/Luxembourgish_language"},"lg":{"639-1":"lg","639-2":"lug","family":"Niger–Congo","name":"Ganda","nativeName":"Luganda","wikiUrl":"https://en.wikipedia.org/wiki/Luganda"},"li":{"639-1":"li","639-2":"lim","family":"Indo-European","name":"Limburgish, Limburgan, Limburger","nativeName":"Limburgs","wikiUrl":"https://en.wikipedia.org/wiki/Limburgish_language"},"ln":{"639-1":"ln","639-2":"lin","family":"Niger–Congo","name":"Lingala","nativeName":"Lingála","wikiUrl":"https://en.wikipedia.org/wiki/Lingala_language"},"lo":{"639-1":"lo","639-2":"lao","family":"Tai–Kadai","name":"Lao","nativeName":"ພາສາລາວ","wikiUrl":"https://en.wikipedia.org/wiki/Lao_language"},"lt":{"639-1":"lt","639-2":"lit","family":"Indo-European","name":"Lithuanian","nativeName":"lietuvių kalba","wikiUrl":"https://en.wikipedia.org/wiki/Lithuanian_language"},"lu":{"639-1":"lu","639-2":"lub","family":"Niger–Congo","name":"Luba-Katanga","nativeName":"Tshiluba","wikiUrl":"https://en.wikipedia.org/wiki/Tshiluba_language"},"lv":{"639-1":"lv","639-2":"lav","family":"Indo-European","name":"Latvian","nativeName":"latviešu valoda","wikiUrl":"https://en.wikipedia.org/wiki/Latvian_language"},"mg":{"639-1":"mg","639-2":"mlg","family":"Austronesian","name":"Malagasy","nativeName":"fiteny malagasy","wikiUrl":"https://en.wikipedia.org/wiki/Malagasy_language"},"mh":{"639-1":"mh","639-2":"mah","family":"Austronesian","name":"Marshallese","nativeName":"Kajin M̧ajeļ","wikiUrl":"https://en.wikipedia.org/wiki/Marshallese_language"},"mi":{"639-1":"mi","639-2":"mri","639-2/B":"mao","family":"Austronesian","name":"Māori","nativeName":"te reo Māori","wikiUrl":"https://en.wikipedia.org/wiki/M%C4%81ori_language"},"mk":{"639-1":"mk","639-2":"mkd","639-2/B":"mac","family":"Indo-European","name":"Macedonian","nativeName":"македонски јазик","wikiUrl":"https://en.wikipedia.org/wiki/Macedonian_language"},"ml":{"639-1":"ml","639-2":"mal","family":"Dravidian","name":"Malayalam","nativeName":"മലയാളം","wikiUrl":"https://en.wikipedia.org/wiki/Malayalam_language"},"mn":{"639-1":"mn","639-2":"mon","family":"Mongolic","name":"Mongolian","nativeName":"Монгол хэл","wikiUrl":"https://en.wikipedia.org/wiki/Mongolian_language"},"mr":{"639-1":"mr","639-2":"mar","family":"Indo-European","name":"Marathi (Marāṭhī)","nativeName":"मराठी","wikiUrl":"https://en.wikipedia.org/wiki/Marathi_language"},"ms":{"639-1":"ms","639-2":"msa","639-2/B":"may","family":"Austronesian","name":"Malay","nativeName":"bahasa Melayu, بهاس ملايو‎","wikiUrl":"https://en.wikipedia.org/wiki/Malay_language"},"mt":{"639-1":"mt","639-2":"mlt","family":"Afro-Asiatic","name":"Maltese","nativeName":"Malti","wikiUrl":"https://en.wikipedia.org/wiki/Maltese_language"},"my":{"639-1":"my","639-2":"mya","639-2/B":"bur","family":"Sino-Tibetan","name":"Burmese","nativeName":"ဗမာစာ","wikiUrl":"https://en.wikipedia.org/wiki/Burmese_language"},"na":{"639-1":"na","639-2":"nau","family":"Austronesian","name":"Nauruan","nativeName":"Dorerin Naoero","wikiUrl":"https://en.wikipedia.org/wiki/Nauruan_language"},"nb":{"639-1":"nb","639-2":"nob","family":"Indo-European","name":"Norwegian Bokmål","nativeName":"Norsk bokmål","wikiUrl":"https://en.wikipedia.org/wiki/Bokm%C3%A5l"},"nd":{"639-1":"nd","639-2":"nde","family":"Niger–Congo","name":"Northern Ndebele","nativeName":"isiNdebele","wikiUrl":"https://en.wikipedia.org/wiki/Northern_Ndebele_language"},"ne":{"639-1":"ne","639-2":"nep","family":"Indo-European","name":"Nepali","nativeName":"नेपाली","wikiUrl":"https://en.wikipedia.org/wiki/Nepali_language"},"ng":{"639-1":"ng","639-2":"ndo","family":"Niger–Congo","name":"Ndonga","nativeName":"Owambo","wikiUrl":"https://en.wikipedia.org/wiki/Ndonga"},"nl":{"639-1":"nl","639-2":"nld","639-2/B":"dut","family":"Indo-European","name":"Dutch","nativeName":"Nederlands, Vlaams","wikiUrl":"https://en.wikipedia.org/wiki/Dutch_language"},"nn":{"639-1":"nn","639-2":"nno","family":"Indo-European","name":"Norwegian Nynorsk","nativeName":"Norsk nynorsk","wikiUrl":"https://en.wikipedia.org/wiki/Nynorsk"},"no":{"639-1":"no","639-2":"nor","family":"Indo-European","name":"Norwegian","nativeName":"Norsk","wikiUrl":"https://en.wikipedia.org/wiki/Norwegian_language"},"nr":{"639-1":"nr","639-2":"nbl","family":"Niger–Congo","name":"Southern Ndebele","nativeName":"isiNdebele","wikiUrl":"https://en.wikipedia.org/wiki/Southern_Ndebele_language"},"nv":{"639-1":"nv","639-2":"nav","family":"Dené–Yeniseian","name":"Navajo, Navaho","nativeName":"Diné bizaad","wikiUrl":"https://en.wikipedia.org/wiki/Navajo_language"},"ny":{"639-1":"ny","639-2":"nya","family":"Niger–Congo","name":"Chichewa, Chewa, Nyanja","nativeName":"chiCheŵa, chinyanja","wikiUrl":"https://en.wikipedia.org/wiki/Chichewa_language"},"oc":{"639-1":"oc","639-2":"oci","family":"Indo-European","name":"Occitan","nativeName":"occitan, lenga d'òc","wikiUrl":"https://en.wikipedia.org/wiki/Occitan_language"},"oj":{"639-1":"oj","639-2":"oji","family":"Algonquian","name":"Ojibwe, Ojibwa","nativeName":"ᐊᓂᔑᓈᐯᒧᐎᓐ","wikiUrl":"https://en.wikipedia.org/wiki/Ojibwe_language"},"om":{"639-1":"om","639-2":"orm","family":"Afro-Asiatic","name":"Oromo","nativeName":"Afaan Oromoo","wikiUrl":"https://en.wikipedia.org/wiki/Oromo_language"},"or":{"639-1":"or","639-2":"ori","family":"Indo-European","name":"Oriya","nativeName":"ଓଡ଼ିଆ","wikiUrl":"https://en.wikipedia.org/wiki/Oriya_language"},"os":{"639-1":"os","639-2":"oss","family":"Indo-European","name":"Ossetian, Ossetic","nativeName":"ирон æвзаг","wikiUrl":"https://en.wikipedia.org/wiki/Ossetic_language"},"pa":{"639-1":"pa","639-2":"pan","family":"Indo-European","name":"(Eastern) Punjabi","nativeName":"ਪੰਜਾਬੀ","wikiUrl":"https://en.wikipedia.org/wiki/Eastern_Punjabi_language"},"pi":{"639-1":"pi","639-2":"pli","family":"Indo-European","name":"Pāli","nativeName":"पाऴि","wikiUrl":"https://en.wikipedia.org/wiki/P%C4%81li_language"},"pl":{"639-1":"pl","639-2":"pol","family":"Indo-European","name":"Polish","nativeName":"język polski, polszczyzna","wikiUrl":"https://en.wikipedia.org/wiki/Polish_language"},"ps":{"639-1":"ps","639-2":"pus","family":"Indo-European","name":"Pashto, Pushto","nativeName":"پښتو","wikiUrl":"https://en.wikipedia.org/wiki/Pashto_language"},"pt":{"639-1":"pt","639-2":"por","family":"Indo-European","name":"Portuguese","nativeName":"Português","wikiUrl":"https://en.wikipedia.org/wiki/Portuguese_language"},"qu":{"639-1":"qu","639-2":"que","family":"Quechuan","name":"Quechua","nativeName":"Runa Simi, Kichwa","wikiUrl":"https://en.wikipedia.org/wiki/Quechua_language"},"rm":{"639-1":"rm","639-2":"roh","family":"Indo-European","name":"Romansh","nativeName":"rumantsch grischun","wikiUrl":"https://en.wikipedia.org/wiki/Romansh_language"},"rn":{"639-1":"rn","639-2":"run","family":"Niger–Congo","name":"Kirundi","nativeName":"Ikirundi","wikiUrl":"https://en.wikipedia.org/wiki/Kirundi"},"ro":{"639-1":"ro","639-2":"ron","639-2/B":"rum","family":"Indo-European","name":"Romanian","nativeName":"Română","wikiUrl":"https://en.wikipedia.org/wiki/Romanian_language"},"ru":{"639-1":"ru","639-2":"rus","family":"Indo-European","name":"Russian","nativeName":"Русский","wikiUrl":"https://en.wikipedia.org/wiki/Russian_language"},"rw":{"639-1":"rw","639-2":"kin","family":"Niger–Congo","name":"Kinyarwanda","nativeName":"Ikinyarwanda","wikiUrl":"https://en.wikipedia.org/wiki/Kinyarwanda"},"sa":{"639-1":"sa","639-2":"san","family":"Indo-European","name":"Sanskrit (Saṁskṛta)","nativeName":"संस्कृतम्","wikiUrl":"https://en.wikipedia.org/wiki/Sanskrit"},"sc":{"639-1":"sc","639-2":"srd","family":"Indo-European","name":"Sardinian","nativeName":"sardu","wikiUrl":"https://en.wikipedia.org/wiki/Sardinian_language"},"sd":{"639-1":"sd","639-2":"snd","family":"Indo-European","name":"Sindhi","nativeName":"सिन्धी, سنڌي، سندھی‎","wikiUrl":"https://en.wikipedia.org/wiki/Sindhi_language"},"se":{"639-1":"se","639-2":"sme","family":"Uralic","name":"Northern Sami","nativeName":"Davvisámegiella","wikiUrl":"https://en.wikipedia.org/wiki/Northern_Sami"},"sg":{"639-1":"sg","639-2":"sag","family":"Creole","name":"Sango","nativeName":"yângâ tî sängö","wikiUrl":"https://en.wikipedia.org/wiki/Sango_language"},"si":{"639-1":"si","639-2":"sin","family":"Indo-European","name":"Sinhalese, Sinhala","nativeName":"සිංහල","wikiUrl":"https://en.wikipedia.org/wiki/Sinhalese_language"},"sk":{"639-1":"sk","639-2":"slk","639-2/B":"slo","family":"Indo-European","name":"Slovak","nativeName":"slovenčina, slovenský jazyk","wikiUrl":"https://en.wikipedia.org/wiki/Slovak_language"},"sl":{"639-1":"sl","639-2":"slv","family":"Indo-European","name":"Slovene","nativeName":"slovenski jezik, slovenščina","wikiUrl":"https://en.wikipedia.org/wiki/Slovene_language"},"sm":{"639-1":"sm","639-2":"smo","family":"Austronesian","name":"Samoan","nativeName":"gagana fa'a Samoa","wikiUrl":"https://en.wikipedia.org/wiki/Samoan_language"},"sn":{"639-1":"sn","639-2":"sna","family":"Niger–Congo","name":"Shona","nativeName":"chiShona","wikiUrl":"https://en.wikipedia.org/wiki/Shona_language"},"so":{"639-1":"so","639-2":"som","family":"Afro-Asiatic","name":"Somali","nativeName":"Soomaaliga, af Soomaali","wikiUrl":"https://en.wikipedia.org/wiki/Somali_language"},"sq":{"639-1":"sq","639-2":"sqi","639-2/B":"alb","family":"Indo-European","name":"Albanian","nativeName":"Shqip","wikiUrl":"https://en.wikipedia.org/wiki/Albanian_language"},"sr":{"639-1":"sr","639-2":"srp","family":"Indo-European","name":"Serbian","nativeName":"српски језик","wikiUrl":"https://en.wikipedia.org/wiki/Serbian_language"},"ss":{"639-1":"ss","639-2":"ssw","family":"Niger–Congo","name":"Swati","nativeName":"SiSwati","wikiUrl":"https://en.wikipedia.org/wiki/Swati_language"},"st":{"639-1":"st","639-2":"sot","family":"Niger–Congo","name":"Southern Sotho","nativeName":"Sesotho","wikiUrl":"https://en.wikipedia.org/wiki/Sotho_language"},"su":{"639-1":"su","639-2":"sun","family":"Austronesian","name":"Sundanese","nativeName":"Basa Sunda","wikiUrl":"https://en.wikipedia.org/wiki/Sundanese_language"},"sv":{"639-1":"sv","639-2":"swe","family":"Indo-European","name":"Swedish","nativeName":"svenska","wikiUrl":"https://en.wikipedia.org/wiki/Swedish_language"},"sw":{"639-1":"sw","639-2":"swa","family":"Niger–Congo","name":"Swahili","nativeName":"Kiswahili","wikiUrl":"https://en.wikipedia.org/wiki/Swahili_language"},"ta":{"639-1":"ta","639-2":"tam","family":"Dravidian","name":"Tamil","nativeName":"தமிழ்","wikiUrl":"https://en.wikipedia.org/wiki/Tamil_language"},"te":{"639-1":"te","639-2":"tel","family":"Dravidian","name":"Telugu","nativeName":"తెలుగు","wikiUrl":"https://en.wikipedia.org/wiki/Telugu_language"},"tg":{"639-1":"tg","639-2":"tgk","family":"Indo-European","name":"Tajik","nativeName":"тоҷикӣ, toçikī, تاجیکی‎","wikiUrl":"https://en.wikipedia.org/wiki/Tajik_language"},"th":{"639-1":"th","639-2":"tha","family":"Tai–Kadai","name":"Thai","nativeName":"ไทย","wikiUrl":"https://en.wikipedia.org/wiki/Thai_language"},"ti":{"639-1":"ti","639-2":"tir","family":"Afro-Asiatic","name":"Tigrinya","nativeName":"ትግርኛ","wikiUrl":"https://en.wikipedia.org/wiki/Tigrinya_language"},"tk":{"639-1":"tk","639-2":"tuk","family":"Turkic","name":"Turkmen","nativeName":"Türkmen, Түркмен","wikiUrl":"https://en.wikipedia.org/wiki/Turkmen_language"},"tl":{"639-1":"tl","639-2":"tgl","family":"Austronesian","name":"Tagalog","nativeName":"Wikang Tagalog","wikiUrl":"https://en.wikipedia.org/wiki/Tagalog_language"},"tn":{"639-1":"tn","639-2":"tsn","family":"Niger–Congo","name":"Tswana","nativeName":"Setswana","wikiUrl":"https://en.wikipedia.org/wiki/Tswana_language"},"to":{"639-1":"to","639-2":"ton","family":"Austronesian","name":"Tonga (Tonga Islands)","nativeName":"faka Tonga","wikiUrl":"https://en.wikipedia.org/wiki/Tongan_language"},"tr":{"639-1":"tr","639-2":"tur","family":"Turkic","name":"Turkish","nativeName":"Türkçe","wikiUrl":"https://en.wikipedia.org/wiki/Turkish_language"},"ts":{"639-1":"ts","639-2":"tso","family":"Niger–Congo","name":"Tsonga","nativeName":"Xitsonga","wikiUrl":"https://en.wikipedia.org/wiki/Tsonga_language"},"tt":{"639-1":"tt","639-2":"tat","family":"Turkic","name":"Tatar","nativeName":"татар теле, tatar tele","wikiUrl":"https://en.wikipedia.org/wiki/Tatar_language"},"tw":{"639-1":"tw","639-2":"twi","family":"Niger–Congo","name":"Twi","nativeName":"Twi","wikiUrl":"https://en.wikipedia.org/wiki/Twi"},"ty":{"639-1":"ty","639-2":"tah","family":"Austronesian","name":"Tahitian","nativeName":"Reo Tahiti","wikiUrl":"https://en.wikipedia.org/wiki/Tahitian_language"},"ug":{"639-1":"ug","639-2":"uig","family":"Turkic","name":"Uyghur","nativeName":"ئۇيغۇرچە‎, Uyghurche","wikiUrl":"https://en.wikipedia.org/wiki/Uyghur_language"},"uk":{"639-1":"uk","639-2":"ukr","family":"Indo-European","name":"Ukrainian","nativeName":"Українська","wikiUrl":"https://en.wikipedia.org/wiki/Ukrainian_language"},"ur":{"639-1":"ur","639-2":"urd","family":"Indo-European","name":"Urdu","nativeName":"اردو","wikiUrl":"https://en.wikipedia.org/wiki/Urdu"},"uz":{"639-1":"uz","639-2":"uzb","family":"Turkic","name":"Uzbek","nativeName":"Oʻzbek, Ўзбек, أۇزبېك‎","wikiUrl":"https://en.wikipedia.org/wiki/Uzbek_language"},"ve":{"639-1":"ve","639-2":"ven","family":"Niger–Congo","name":"Venda","nativeName":"Tshivenḓa","wikiUrl":"https://en.wikipedia.org/wiki/Venda_language"},"vi":{"639-1":"vi","639-2":"vie","family":"Austroasiatic","name":"Vietnamese","nativeName":"Tiếng Việt","wikiUrl":"https://en.wikipedia.org/wiki/Vietnamese_language"},"vo":{"639-1":"vo","639-2":"vol","family":"Constructed","name":"Volapük","nativeName":"Volapük","wikiUrl":"https://en.wikipedia.org/wiki/Volap%C3%BCk"},"wa":{"639-1":"wa","639-2":"wln","family":"Indo-European","name":"Walloon","nativeName":"walon","wikiUrl":"https://en.wikipedia.org/wiki/Walloon_language"},"wo":{"639-1":"wo","639-2":"wol","family":"Niger–Congo","name":"Wolof","nativeName":"Wollof","wikiUrl":"https://en.wikipedia.org/wiki/Wolof_language"},"xh":{"639-1":"xh","639-2":"xho","family":"Niger–Congo","name":"Xhosa","nativeName":"isiXhosa","wikiUrl":"https://en.wikipedia.org/wiki/Xhosa_language"},"yi":{"639-1":"yi","639-2":"yid","family":"Indo-European","name":"Yiddish","nativeName":"ייִדיש","wikiUrl":"https://en.wikipedia.org/wiki/Yiddish_language"},"yo":{"639-1":"yo","639-2":"yor","family":"Niger–Congo","name":"Yoruba","nativeName":"Yorùbá","wikiUrl":"https://en.wikipedia.org/wiki/Yoruba_language"},"za":{"639-1":"za","639-2":"zha","family":"Tai–Kadai","name":"Zhuang, Chuang","nativeName":"Saɯ cueŋƅ, Saw cuengh","wikiUrl":"https://en.wikipedia.org/wiki/Zhuang_languages"},"zh":{"639-1":"zh","639-2":"zho","639-2/B":"chi","family":"Sino-Tibetan","name":"Chinese","nativeName":"中文 (Zhōngwén), 汉语, 漢語","wikiUrl":"https://en.wikipedia.org/wiki/Chinese_language"},"zu":{"639-1":"zu","639-2":"zul","family":"Niger–Congo","name":"Zulu","nativeName":"isiZulu","wikiUrl":"https://en.wikipedia.org/wiki/Zulu_language"}} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 000000000..4039870af --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:io' show Platform; +import 'package:media_kit/media_kit.dart'; +import 'package:window_manager/window_manager.dart'; +import 'client/plex_client.dart'; +import 'config/plex_config.dart'; +import 'screens/main_screen.dart'; +import 'screens/auth_screen.dart'; +import 'services/storage_service.dart'; +import 'services/plex_auth_service.dart'; +import 'services/macos_titlebar_service.dart'; +import 'services/fullscreen_state_manager.dart'; +import 'models/plex_user_profile.dart'; +import 'utils/language_codes.dart'; +import 'utils/app_logger.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize window_manager for desktop platforms + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + await windowManager.ensureInitialized(); + } + + // Configure macOS window with custom titlebar + await MacOSTitlebarService.setupCustomTitlebar(); + + // Initialize MediaKit + MediaKit.ensureInitialized(); + + // Lock orientation to portrait for all screens except video player + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + await StorageService.getInstance(); + + // Initialize language codes for track selection + await LanguageCodes.initialize(); + + // Start global fullscreen state monitoring + FullscreenStateManager().startMonitoring(); + + // DTD service is available for MCP tooling connection if needed + + runApp(const MainApp()); +} + +// Global RouteObserver for tracking navigation +final RouteObserver routeObserver = RouteObserver(); + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Plezy', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange), + useMaterial3: true, + ), + darkTheme: ThemeData.dark(useMaterial3: true), + navigatorObservers: [routeObserver], + home: const SetupScreen(), + ); + } +} + +class SetupScreen extends StatefulWidget { + const SetupScreen({super.key}); + + @override + State createState() => _SetupScreenState(); +} + +class _SetupScreenState extends State { + @override + void initState() { + super.initState(); + _loadSavedCredentials(); + } + + Future _loadSavedCredentials() async { + final storage = await StorageService.getInstance(); + + // Check if we have server data + final serverData = storage.getServerData(); + final clientId = storage.getClientIdentifier(); + final plexToken = storage.getPlexToken(); + + if (serverData != null && clientId != null) { + try { + // Recreate PlexServer from stored data + final server = PlexServer.fromJson(serverData); + + // Test connections to find best working one + final connection = await server.findBestWorkingConnection(); + + if (connection != null) { + // Update stored server URL with working connection + await storage.saveServerUrl(connection.uri); + + // Create client with working connection + final config = PlexConfig( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientId, + ); + final client = PlexClient(config); + + // Verify server is accessible + try { + await client.getServerIdentity(); + + // Fetch and cache user profile if we have a plex token + PlexUserProfile? userProfile; + if (plexToken != null) { + userProfile = await _fetchAndCacheUserProfile(plexToken); + } + + // Success! Navigate to main screen + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + MainScreen(client: client, userProfile: userProfile), + ), + ); + return; + } + } catch (e) { + // Server identity check failed + await storage.clearCredentials(); + } + } else { + // No working connections found + await storage.clearCredentials(); + } + } catch (e) { + // Error loading or testing server + await storage.clearCredentials(); + } + } + + // No saved credentials or auto-login failed - show auth screen + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const AuthScreen()), + ); + } + } + + Future _fetchAndCacheUserProfile(String plexToken) async { + appLogger.d('Fetching user profile from Plex API'); + try { + final authService = await PlexAuthService.create(); + final profile = await authService.getUserProfile(plexToken); + + appLogger.i( + 'Successfully fetched user profile', + error: { + 'autoSelectAudio': profile.autoSelectAudio, + 'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set', + 'autoSelectSubtitle': profile.autoSelectSubtitle, + 'defaultSubtitleLanguage': + profile.defaultSubtitleLanguage ?? 'not set', + 'defaultSubtitleForced': profile.defaultSubtitleForced, + }, + ); + + // Cache the profile + final storage = await StorageService.getInstance(); + await storage.saveUserProfile(profile.toJson()); + appLogger.d('User profile cached locally'); + + return profile; + } catch (e) { + appLogger.w( + 'Failed to fetch user profile from API, attempting to load from cache', + error: e, + ); + + // Failed to fetch profile, try to load from cache + final storage = await StorageService.getInstance(); + final cachedProfile = storage.getUserProfile(); + if (cachedProfile != null) { + final profile = PlexUserProfile.fromJson(cachedProfile); + appLogger.i( + 'Loaded user profile from cache', + error: { + 'autoSelectAudio': profile.autoSelectAudio, + 'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set', + 'autoSelectSubtitle': profile.autoSelectSubtitle, + 'defaultSubtitleLanguage': + profile.defaultSubtitleLanguage ?? 'not set', + 'defaultSubtitleForced': profile.defaultSubtitleForced, + }, + ); + return profile; + } + + appLogger.w( + 'No cached user profile available, track selection will use defaults', + ); + return null; + } + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ); + } +} diff --git a/lib/mixins/refreshable.dart b/lib/mixins/refreshable.dart new file mode 100644 index 000000000..065e83e4e --- /dev/null +++ b/lib/mixins/refreshable.dart @@ -0,0 +1,3 @@ +mixin Refreshable { + void refresh(); +} diff --git a/lib/models/media_container.dart b/lib/models/media_container.dart new file mode 100644 index 000000000..f77a29fe8 --- /dev/null +++ b/lib/models/media_container.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'plex_metadata.dart'; +import 'plex_library.dart'; + +part 'media_container.g.dart'; + +@JsonSerializable() +class MediaContainer { + final int? size; + final int? totalSize; + final int? offset; + final String? identifier; + @JsonKey(name: 'Directory') + final List? directories; + @JsonKey(name: 'Metadata') + final List? metadata; + + MediaContainer({ + this.size, + this.totalSize, + this.offset, + this.identifier, + this.directories, + this.metadata, + }); + + factory MediaContainer.fromJson(Map json) => + _$MediaContainerFromJson(json); + + Map toJson() => _$MediaContainerToJson(this); +} diff --git a/lib/models/media_container.g.dart b/lib/models/media_container.g.dart new file mode 100644 index 000000000..2ed4e9e04 --- /dev/null +++ b/lib/models/media_container.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_container.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MediaContainer _$MediaContainerFromJson(Map json) => + MediaContainer( + size: (json['size'] as num?)?.toInt(), + totalSize: (json['totalSize'] as num?)?.toInt(), + offset: (json['offset'] as num?)?.toInt(), + identifier: json['identifier'] as String?, + directories: (json['Directory'] as List?) + ?.map((e) => PlexLibrary.fromJson(e as Map)) + .toList(), + metadata: (json['Metadata'] as List?) + ?.map((e) => PlexMetadata.fromJson(e as Map)) + .toList(), + ); + +Map _$MediaContainerToJson(MediaContainer instance) => + { + 'size': instance.size, + 'totalSize': instance.totalSize, + 'offset': instance.offset, + 'identifier': instance.identifier, + 'Directory': instance.directories, + 'Metadata': instance.metadata, + }; diff --git a/lib/models/plex_filter.dart b/lib/models/plex_filter.dart new file mode 100644 index 000000000..6f76a14ae --- /dev/null +++ b/lib/models/plex_filter.dart @@ -0,0 +1,55 @@ +class PlexFilter { + final String filter; + final String filterType; + final String key; + final String title; + final String type; + + PlexFilter({ + required this.filter, + required this.filterType, + required this.key, + required this.title, + required this.type, + }); + + factory PlexFilter.fromJson(Map json) { + return PlexFilter( + filter: json['filter'] ?? '', + filterType: json['filterType'] ?? 'string', + key: json['key'] ?? '', + title: json['title'] ?? '', + type: json['type'] ?? 'filter', + ); + } + + Map toJson() { + return { + 'filter': filter, + 'filterType': filterType, + 'key': key, + 'title': title, + 'type': type, + }; + } +} + +class PlexFilterValue { + final String key; + final String title; + final String? type; + + PlexFilterValue({required this.key, required this.title, this.type}); + + factory PlexFilterValue.fromJson(Map json) { + return PlexFilterValue( + key: json['key'] ?? '', + title: json['title'] ?? '', + type: json['type'], + ); + } + + Map toJson() { + return {'key': key, 'title': title, if (type != null) 'type': type}; + } +} diff --git a/lib/models/plex_library.dart b/lib/models/plex_library.dart new file mode 100644 index 000000000..10ee2f312 --- /dev/null +++ b/lib/models/plex_library.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_library.g.dart'; + +@JsonSerializable() +class PlexLibrary { + final String key; + final String title; + final String type; + final String? agent; + final String? scanner; + final String? language; + final String? uuid; + final int? updatedAt; + final int? createdAt; + + PlexLibrary({ + required this.key, + required this.title, + required this.type, + this.agent, + this.scanner, + this.language, + this.uuid, + this.updatedAt, + this.createdAt, + }); + + factory PlexLibrary.fromJson(Map json) => + _$PlexLibraryFromJson(json); + + Map toJson() => _$PlexLibraryToJson(this); +} diff --git a/lib/models/plex_library.g.dart b/lib/models/plex_library.g.dart new file mode 100644 index 000000000..1f77d21a6 --- /dev/null +++ b/lib/models/plex_library.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_library.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexLibrary _$PlexLibraryFromJson(Map json) => PlexLibrary( + key: json['key'] as String, + title: json['title'] as String, + type: json['type'] as String, + agent: json['agent'] as String?, + scanner: json['scanner'] as String?, + language: json['language'] as String?, + uuid: json['uuid'] as String?, + updatedAt: (json['updatedAt'] as num?)?.toInt(), + createdAt: (json['createdAt'] as num?)?.toInt(), +); + +Map _$PlexLibraryToJson(PlexLibrary instance) => + { + 'key': instance.key, + 'title': instance.title, + 'type': instance.type, + 'agent': instance.agent, + 'scanner': instance.scanner, + 'language': instance.language, + 'uuid': instance.uuid, + 'updatedAt': instance.updatedAt, + 'createdAt': instance.createdAt, + }; diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart new file mode 100644 index 000000000..ad8c73873 --- /dev/null +++ b/lib/models/plex_media_info.dart @@ -0,0 +1,104 @@ +class PlexMediaInfo { + final String videoUrl; + final List audioTracks; + final List subtitleTracks; + final List chapters; + + PlexMediaInfo({ + required this.videoUrl, + required this.audioTracks, + required this.subtitleTracks, + required this.chapters, + }); +} + +class PlexAudioTrack { + final int id; + final int? index; + final String? codec; + final String? language; + final String? languageCode; + final String? title; + final String? displayTitle; + final int? channels; + final bool selected; + + PlexAudioTrack({ + required this.id, + this.index, + this.codec, + this.language, + this.languageCode, + this.title, + this.displayTitle, + this.channels, + required this.selected, + }); + + String get label { + if (displayTitle != null) return displayTitle!; + final parts = []; + if (language != null) parts.add(language!); + if (codec != null) parts.add(codec!.toUpperCase()); + if (channels != null) parts.add('${channels!}ch'); + return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + } +} + +class PlexSubtitleTrack { + final int id; + final int? index; + final String? codec; + final String? language; + final String? languageCode; + final String? title; + final String? displayTitle; + final bool selected; + final bool forced; + final String? key; + + PlexSubtitleTrack({ + required this.id, + this.index, + this.codec, + this.language, + this.languageCode, + this.title, + this.displayTitle, + required this.selected, + required this.forced, + this.key, + }); + + String get label { + if (displayTitle != null) return displayTitle!; + final parts = []; + if (language != null) parts.add(language!); + if (forced) parts.add('Forced'); + return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + } +} + +class PlexChapter { + final int id; + final int? index; + final int? startTimeOffset; + final int? endTimeOffset; + final String? title; + final String? thumb; + + PlexChapter({ + required this.id, + this.index, + this.startTimeOffset, + this.endTimeOffset, + this.title, + this.thumb, + }); + + String get label => title ?? 'Chapter ${(index ?? 0) + 1}'; + + Duration get startTime => Duration(milliseconds: startTimeOffset ?? 0); + Duration? get endTime => + endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null; +} diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart new file mode 100644 index 000000000..4d5d8f396 --- /dev/null +++ b/lib/models/plex_metadata.dart @@ -0,0 +1,150 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_metadata.g.dart'; + +@JsonSerializable() +class PlexMetadata { + final String ratingKey; + final String key; + final String? guid; + final String? studio; + final String type; + final String title; + final String? contentRating; + final String? summary; + final int? rating; + final int? year; + final String? thumb; + final String? art; + final int? duration; + final int? addedAt; + final int? updatedAt; + final String? grandparentTitle; // Show title for episodes + final String? grandparentThumb; // Show poster for episodes + final String? grandparentArt; // Show art for episodes + final String? grandparentRatingKey; // Show rating key for episodes + final String? parentTitle; // Season title for episodes + final String? parentRatingKey; // Season rating key for episodes + final int? parentIndex; // Season number + final int? index; // Episode number + final String? grandparentTheme; // Show theme music + final int? viewOffset; // Resume position in ms + final int? viewCount; + final int? leafCount; // Total number of episodes in a series/season + final int? viewedLeafCount; // Number of watched episodes in a series/season + + // Transient field for clear logo (extracted from Image array) + String? _clearLogo; + String? get clearLogo => _clearLogo; + + PlexMetadata({ + required this.ratingKey, + required this.key, + this.guid, + this.studio, + required this.type, + required this.title, + this.contentRating, + this.summary, + this.rating, + this.year, + this.thumb, + this.art, + this.duration, + this.addedAt, + this.updatedAt, + this.grandparentTitle, + this.grandparentThumb, + this.grandparentArt, + this.grandparentRatingKey, + this.parentTitle, + this.parentRatingKey, + this.parentIndex, + this.index, + this.grandparentTheme, + this.viewOffset, + this.viewCount, + this.leafCount, + this.viewedLeafCount, + }); + + // Extract clearLogo from Image array in raw JSON + void _extractClearLogo(Map json) { + if (!json.containsKey('Image')) return; + + final images = json['Image'] as List?; + if (images == null) return; + + for (var image in images) { + if (image is Map && image['type'] == 'clearLogo') { + _clearLogo = image['url'] as String?; + return; + } + } + } + + // Custom factory that extracts clearLogo + factory PlexMetadata.fromJsonWithImages(Map json) { + final metadata = PlexMetadata.fromJson(json); + metadata._extractClearLogo(json); + return metadata; + } + + // Helper to get the display title (show name for episodes/seasons, title otherwise) + String get displayTitle { + final itemType = type.toLowerCase(); + + // For episodes and seasons, prefer grandparent title (show name) + if ((itemType == 'episode' || itemType == 'season') && + grandparentTitle != null) { + return grandparentTitle!; + } + // For seasons without grandparent, check if this IS the show (parentTitle might have show name) + if (itemType == 'season' && parentTitle != null) { + return parentTitle!; + } + return title; + } + + // Helper to get the subtitle (episode/season title) + String? get displaySubtitle { + final itemType = type.toLowerCase(); + + if (itemType == 'episode' || itemType == 'season') { + // If we showed grandparent/parent as title, show this item's title as subtitle + if (grandparentTitle != null || + (itemType == 'season' && parentTitle != null)) { + return title; + } + } + return null; + } + + // Helper to get the poster (show poster for episodes/seasons, thumb otherwise) + String? get posterThumb { + final itemType = type.toLowerCase(); + + // For episodes and seasons, prefer grandparent thumb (show poster) + if ((itemType == 'episode' || itemType == 'season') && + grandparentThumb != null) { + return grandparentThumb!; + } + return thumb; + } + + // Helper to determine if content is watched + bool get isWatched { + // For series/seasons, check if all episodes are watched + if (leafCount != null && viewedLeafCount != null) { + return viewedLeafCount! >= leafCount!; + } + + // For individual items (movies, episodes), check viewCount + return viewCount != null && viewCount! > 0; + } + + factory PlexMetadata.fromJson(Map json) => + _$PlexMetadataFromJson(json); + + Map toJson() => _$PlexMetadataToJson(this); +} diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart new file mode 100644 index 000000000..1d0901ac9 --- /dev/null +++ b/lib/models/plex_metadata.g.dart @@ -0,0 +1,70 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_metadata.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( + ratingKey: json['ratingKey'] as String, + key: json['key'] as String, + guid: json['guid'] as String?, + studio: json['studio'] as String?, + type: json['type'] as String, + title: json['title'] as String, + contentRating: json['contentRating'] as String?, + summary: json['summary'] as String?, + rating: (json['rating'] as num?)?.toInt(), + year: (json['year'] as num?)?.toInt(), + thumb: json['thumb'] as String?, + art: json['art'] as String?, + duration: (json['duration'] as num?)?.toInt(), + addedAt: (json['addedAt'] as num?)?.toInt(), + updatedAt: (json['updatedAt'] as num?)?.toInt(), + grandparentTitle: json['grandparentTitle'] as String?, + grandparentThumb: json['grandparentThumb'] as String?, + grandparentArt: json['grandparentArt'] as String?, + grandparentRatingKey: json['grandparentRatingKey'] as String?, + parentTitle: json['parentTitle'] as String?, + parentRatingKey: json['parentRatingKey'] as String?, + parentIndex: (json['parentIndex'] as num?)?.toInt(), + index: (json['index'] as num?)?.toInt(), + grandparentTheme: json['grandparentTheme'] as String?, + viewOffset: (json['viewOffset'] as num?)?.toInt(), + viewCount: (json['viewCount'] as num?)?.toInt(), + leafCount: (json['leafCount'] as num?)?.toInt(), + viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(), +); + +Map _$PlexMetadataToJson(PlexMetadata instance) => + { + 'ratingKey': instance.ratingKey, + 'key': instance.key, + 'guid': instance.guid, + 'studio': instance.studio, + 'type': instance.type, + 'title': instance.title, + 'contentRating': instance.contentRating, + 'summary': instance.summary, + 'rating': instance.rating, + 'year': instance.year, + 'thumb': instance.thumb, + 'art': instance.art, + 'duration': instance.duration, + 'addedAt': instance.addedAt, + 'updatedAt': instance.updatedAt, + 'grandparentTitle': instance.grandparentTitle, + 'grandparentThumb': instance.grandparentThumb, + 'grandparentArt': instance.grandparentArt, + 'grandparentRatingKey': instance.grandparentRatingKey, + 'parentTitle': instance.parentTitle, + 'parentRatingKey': instance.parentRatingKey, + 'parentIndex': instance.parentIndex, + 'index': instance.index, + 'grandparentTheme': instance.grandparentTheme, + 'viewOffset': instance.viewOffset, + 'viewCount': instance.viewCount, + 'leafCount': instance.leafCount, + 'viewedLeafCount': instance.viewedLeafCount, + }; diff --git a/lib/models/plex_server_info.dart b/lib/models/plex_server_info.dart new file mode 100644 index 000000000..35b586dbf --- /dev/null +++ b/lib/models/plex_server_info.dart @@ -0,0 +1,29 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_server_info.g.dart'; + +@JsonSerializable() +class PlexServerInfo { + final String name; + final String? host; + final int? port; + final String? machineIdentifier; + final String version; + final bool? owned; + final bool? https; + + PlexServerInfo({ + required this.name, + this.host, + this.port, + this.machineIdentifier, + required this.version, + this.owned, + this.https, + }); + + factory PlexServerInfo.fromJson(Map json) => + _$PlexServerInfoFromJson(json); + + Map toJson() => _$PlexServerInfoToJson(this); +} diff --git a/lib/models/plex_server_info.g.dart b/lib/models/plex_server_info.g.dart new file mode 100644 index 000000000..d36d31261 --- /dev/null +++ b/lib/models/plex_server_info.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_server_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexServerInfo _$PlexServerInfoFromJson(Map json) => + PlexServerInfo( + name: json['name'] as String, + host: json['host'] as String?, + port: (json['port'] as num?)?.toInt(), + machineIdentifier: json['machineIdentifier'] as String?, + version: json['version'] as String, + owned: json['owned'] as bool?, + https: json['https'] as bool?, + ); + +Map _$PlexServerInfoToJson(PlexServerInfo instance) => + { + 'name': instance.name, + 'host': instance.host, + 'port': instance.port, + 'machineIdentifier': instance.machineIdentifier, + 'version': instance.version, + 'owned': instance.owned, + 'https': instance.https, + }; diff --git a/lib/models/plex_user_profile.dart b/lib/models/plex_user_profile.dart new file mode 100644 index 000000000..5c0e04242 --- /dev/null +++ b/lib/models/plex_user_profile.dart @@ -0,0 +1,83 @@ +/// Represents a Plex user's profile preferences +/// Fetched from https://clients.plex.tv/api/v2/user +class PlexUserProfile { + final bool autoSelectAudio; + final int defaultAudioAccessibility; + final String? defaultAudioLanguage; + final List? defaultAudioLanguages; + final String? defaultSubtitleLanguage; + final List? defaultSubtitleLanguages; + final int autoSelectSubtitle; + final int defaultSubtitleAccessibility; + final int defaultSubtitleForced; + final int watchedIndicator; + final int mediaReviewsVisibility; + final List? mediaReviewsLanguages; + + PlexUserProfile({ + required this.autoSelectAudio, + required this.defaultAudioAccessibility, + this.defaultAudioLanguage, + this.defaultAudioLanguages, + this.defaultSubtitleLanguage, + this.defaultSubtitleLanguages, + required this.autoSelectSubtitle, + required this.defaultSubtitleAccessibility, + required this.defaultSubtitleForced, + required this.watchedIndicator, + required this.mediaReviewsVisibility, + this.mediaReviewsLanguages, + }); + + factory PlexUserProfile.fromJson(Map json) { + final profile = json['profile'] as Map? ?? json; + + return PlexUserProfile( + autoSelectAudio: profile['autoSelectAudio'] as bool? ?? true, + defaultAudioAccessibility: + profile['defaultAudioAccessibility'] as int? ?? 0, + defaultAudioLanguage: profile['defaultAudioLanguage'] as String?, + defaultAudioLanguages: profile['defaultAudioLanguages'] != null + ? List.from(profile['defaultAudioLanguages'] as List) + : null, + defaultSubtitleLanguage: profile['defaultSubtitleLanguage'] as String?, + defaultSubtitleLanguages: profile['defaultSubtitleLanguages'] != null + ? List.from(profile['defaultSubtitleLanguages'] as List) + : null, + autoSelectSubtitle: profile['autoSelectSubtitle'] as int? ?? 0, + defaultSubtitleAccessibility: + profile['defaultSubtitleAccessibility'] as int? ?? 0, + defaultSubtitleForced: profile['defaultSubtitleForced'] as int? ?? 1, + watchedIndicator: profile['watchedIndicator'] as int? ?? 1, + mediaReviewsVisibility: profile['mediaReviewsVisibility'] as int? ?? 0, + mediaReviewsLanguages: profile['mediaReviewsLanguages'] != null + ? List.from(profile['mediaReviewsLanguages'] as List) + : null, + ); + } + + Map toJson() { + return { + 'profile': { + 'autoSelectAudio': autoSelectAudio, + 'defaultAudioAccessibility': defaultAudioAccessibility, + 'defaultAudioLanguage': defaultAudioLanguage, + 'defaultAudioLanguages': defaultAudioLanguages, + 'defaultSubtitleLanguage': defaultSubtitleLanguage, + 'defaultSubtitleLanguages': defaultSubtitleLanguages, + 'autoSelectSubtitle': autoSelectSubtitle, + 'defaultSubtitleAccessibility': defaultSubtitleAccessibility, + 'defaultSubtitleForced': defaultSubtitleForced, + 'watchedIndicator': watchedIndicator, + 'mediaReviewsVisibility': mediaReviewsVisibility, + 'mediaReviewsLanguages': mediaReviewsLanguages, + }, + }; + } + + /// Returns true if subtitles should be automatically selected + bool get shouldAutoSelectSubtitle => autoSelectSubtitle > 0; + + /// Returns true if forced subtitles should be preferred + bool get preferForcedSubtitles => defaultSubtitleForced == 1; +} diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart new file mode 100644 index 000000000..b6732ee1f --- /dev/null +++ b/lib/screens/auth_screen.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../services/plex_auth_service.dart'; +import '../services/storage_service.dart'; +import 'server_selection_screen.dart'; + +class AuthScreen extends StatefulWidget { + const AuthScreen({super.key}); + + @override + State createState() => _AuthScreenState(); +} + +class _AuthScreenState extends State { + bool _isAuthenticating = false; + String? _errorMessage; + late PlexAuthService _authService; + + @override + void initState() { + super.initState(); + _initializeAuthService(); + } + + Future _initializeAuthService() async { + _authService = await PlexAuthService.create(); + } + + Future _startAuthentication() async { + setState(() { + _isAuthenticating = true; + _errorMessage = null; + }); + + try { + // Create a PIN + final pinData = await _authService.createPin(); + final pinId = pinData['id'] as int; + final pinCode = pinData['code'] as String; + + // Construct auth URL + final authUrl = _authService.getAuthUrl(pinCode); + + // Open browser (in-app for mobile, external for desktop) + final uri = Uri.parse(authUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + } else { + throw Exception('Could not launch auth URL'); + } + + // Poll for authentication + final token = await _authService.pollPinUntilClaimed(pinId); + + if (token == null) { + setState(() { + _isAuthenticating = false; + _errorMessage = 'Authentication timed out. Please try again.'; + }); + return; + } + + // Store the token + final storage = await StorageService.getInstance(); + await storage.savePlexToken(token); + + // Navigate to server selection + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => ServerSelectionScreen( + authService: _authService, + plexToken: token, + ), + ), + ); + } + } catch (e) { + setState(() { + _isAuthenticating = false; + _errorMessage = 'Authentication failed: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.video_library, + size: 80, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Plezy', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + if (_isAuthenticating) ...[ + const Center(child: CircularProgressIndicator()), + const SizedBox(height: 16), + const Text( + 'Waiting for authentication...\nPlease complete sign-in in your browser.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ] else ...[ + ElevatedButton( + onPressed: _startAuthentication, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text('Sign in with Plex'), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ], + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart new file mode 100644 index 000000000..e92be9c3f --- /dev/null +++ b/lib/screens/discover_screen.dart @@ -0,0 +1,810 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; +import '../config/plex_config.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../services/storage_service.dart'; +import '../services/plex_auth_service.dart'; +import '../widgets/media_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../widgets/server_list_tile.dart'; +import '../mixins/refreshable.dart'; +import '../utils/app_logger.dart'; +import 'video_player_screen.dart'; +import 'main_screen.dart'; + +class DiscoverScreen extends StatefulWidget { + final PlexClient client; + final PlexUserProfile? userProfile; + final VoidCallback? onBecameVisible; + + const DiscoverScreen({ + super.key, + required this.client, + this.userProfile, + this.onBecameVisible, + }); + + @override + State createState() => _DiscoverScreenState(); +} + +class _DiscoverScreenState extends State with Refreshable { + List _onDeck = []; + List _recentlyAdded = []; + bool _isLoading = true; + String? _errorMessage; + final PageController _heroController = PageController(); + int _currentHeroIndex = 0; + Timer? _autoScrollTimer; + + @override + void initState() { + super.initState(); + _loadContent(); + _startAutoScroll(); + } + + @override + void dispose() { + _autoScrollTimer?.cancel(); + _heroController.dispose(); + super.dispose(); + } + + void _startAutoScroll() { + _autoScrollTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + if (_onDeck.isEmpty || !_heroController.hasClients) return; + + final nextPage = (_currentHeroIndex + 1) % _onDeck.length; + _heroController.animateToPage( + nextPage, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + }); + } + + void _resetAutoScrollTimer() { + _autoScrollTimer?.cancel(); + _startAutoScroll(); + } + + Future _loadContent() async { + appLogger.d('Loading discover content'); + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + appLogger.d('Fetching onDeck and recentlyAdded from Plex'); + final onDeck = await widget.client.getOnDeck(); + final recentlyAdded = await widget.client.getRecentlyAdded(limit: 20); + + appLogger.d( + 'Received ${onDeck.length} on deck items and ${recentlyAdded.length} recently added items', + ); + setState(() { + _onDeck = onDeck; + _recentlyAdded = recentlyAdded; + _isLoading = false; + }); + appLogger.d('Discover content loaded successfully'); + } catch (e) { + appLogger.e('Failed to load discover content', error: e); + setState(() { + _errorMessage = 'Failed to load content: $e'; + _isLoading = false; + }); + } + } + + // Public method to refresh content + @override + void refresh() { + appLogger.d('DiscoverScreen.refresh() called'); + _loadContent(); + } + + Future _handleSwitchServer() async { + final storage = await StorageService.getInstance(); + final plexToken = storage.getPlexToken(); + + if (plexToken == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No Plex token found. Please login again.'), + ), + ); + } + return; + } + + // Show loading dialog + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading servers...'), + ], + ), + ), + ); + } + + try { + // Fetch available servers + final authService = await PlexAuthService.create(); + final servers = await authService.fetchServers(plexToken); + + // Close loading dialog + if (mounted) { + Navigator.pop(context); + } + + if (servers.isEmpty) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('No servers found'))); + } + return; + } + + // Show server selection dialog + if (mounted) { + final selectedServer = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Switch Server'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: servers.length, + itemBuilder: (context, index) { + final server = servers[index]; + return ServerListTile( + server: server, + onTap: () => Navigator.pop(context, server), + showTrailingIcon: false, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ); + + if (selectedServer != null) { + await _connectToServer(selectedServer); + } + } + } catch (e) { + // Close loading dialog if still open + if (mounted) { + Navigator.pop(context); + } + + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to load servers: $e'))); + } + } + } + + Future _connectToServer(PlexServer server) async { + // Show loading dialog + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Testing connections...'), + ], + ), + ), + ); + } + + // Test connections to find best working one + final connection = await server.findBestWorkingConnection(); + + // Close loading dialog + if (mounted) { + Navigator.pop(context); + } + + if (connection == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No working connections found for this server'), + ), + ); + } + return; + } + + // Store server information + final storage = await StorageService.getInstance(); + await storage.saveServerData(server.toJson()); + await storage.saveServerUrl(connection.uri); + await storage.saveServerAccessToken(server.accessToken); + + // Get client identifier + final clientId = storage.getClientIdentifier(); + if (clientId == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Client identifier not found')), + ); + } + return; + } + + // Create new client + final config = PlexConfig( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientId, + ); + final client = PlexClient(config); + + // Replace current screen with main screen (includes bottom nav) + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => MainScreen(client: client)), + ); + } + } + + Future _handleLogout() async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Logout'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + final storage = await StorageService.getInstance(); + await storage.clearCredentials(); + + if (mounted) { + Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + DesktopSliverAppBar( + title: const Text('Discover'), + floating: true, + pinned: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + scrolledUnderElevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadContent, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + if (value == 'switch_server') { + _handleSwitchServer(); + } else if (value == 'logout') { + _handleLogout(); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'switch_server', + child: Row( + children: [ + Icon(Icons.swap_horiz), + SizedBox(width: 8), + Text('Switch Server'), + ], + ), + ), + const PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 8), + Text('Logout'), + ], + ), + ), + ], + ), + ], + ), + if (_isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadContent, + child: const Text('Retry'), + ), + ], + ), + ), + ), + if (!_isLoading && _errorMessage == null) ...[ + // Hero Section (Continue Watching) + if (_onDeck.isNotEmpty) _buildHeroSection(), + + // On Deck / Continue Watching + if (_onDeck.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + children: [ + const Icon(Icons.play_circle_outline), + const SizedBox(width: 8), + Text( + 'Continue Watching', + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + ), + ), + _buildHorizontalList(_onDeck, isLarge: false), + ], + + // Recently Added + if (_recentlyAdded.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + children: [ + const Icon(Icons.fiber_new), + const SizedBox(width: 8), + Text( + 'Recently Added', + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + ), + ), + _buildHorizontalList(_recentlyAdded, isLarge: false), + ], + + if (_onDeck.isEmpty && _recentlyAdded.isEmpty) + const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.movie_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text('No content available'), + SizedBox(height: 8), + Text( + 'Add some media to your libraries', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ], + ), + ), + ); + } + + Widget _buildHeroSection() { + return SliverToBoxAdapter( + child: SizedBox( + height: 500, + child: Stack( + children: [ + PageView.builder( + controller: _heroController, + itemCount: _onDeck.length, + onPageChanged: (index) { + setState(() { + _currentHeroIndex = index; + }); + _resetAutoScrollTimer(); + }, + itemBuilder: (context, index) { + return _buildHeroItem(_onDeck[index]); + }, + ), + // Page indicators + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + _onDeck.length, + (index) => Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: _currentHeroIndex == index ? 24 : 8, + height: 8, + decoration: BoxDecoration( + color: _currentHeroIndex == index + ? Colors.white + : Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeroItem(PlexMetadata heroItem) { + final isEpisode = heroItem.type.toLowerCase() == 'episode'; + final showName = heroItem.grandparentTitle ?? heroItem.title; + final episodeInfo = + isEpisode && heroItem.parentIndex != null && heroItem.index != null + ? 'S${heroItem.parentIndex} · E${heroItem.index} · ${heroItem.title}' + : null; + + return GestureDetector( + onTap: () { + appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: heroItem, + userProfile: widget.userProfile, + ), + ), + ); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + fit: StackFit.expand, + children: [ + // Background Image - use episode art or grandparent art + if (heroItem.art != null || heroItem.grandparentArt != null) + CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + heroItem.art ?? heroItem.grandparentArt, + ), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ) + else + Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + + // Gradient Overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0.9), + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + ), + + // Content + Positioned( + bottom: 70, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Show logo or name/title + if (heroItem.clearLogo != null) + SizedBox( + height: 120, + width: 400, + child: CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + heroItem.clearLogo, + ), + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + placeholder: (context, url) => Align( + alignment: Alignment.centerLeft, + child: Text( + showName, + style: Theme.of(context).textTheme.displaySmall + ?.copyWith( + color: Colors.white.withValues( + alpha: 0.3, + ), + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + errorWidget: (context, url, error) { + // Fallback to text if logo fails to load + return Align( + alignment: Alignment.centerLeft, + child: Text( + showName, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), + ) + else + Text( + showName, + style: Theme.of(context).textTheme.displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + // Episode info + if (episodeInfo != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + episodeInfo, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + + // Summary + if (heroItem.summary != null) ...[ + const SizedBox(height: 12), + Text( + heroItem.summary!, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.4, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + + const SizedBox(height: 20), + + // Play Button + FilledButton.icon( + onPressed: () { + appLogger.d('Playing: ${heroItem.title}'); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: heroItem, + userProfile: widget.userProfile, + ), + ), + ); + }, + icon: const Icon(Icons.play_arrow, size: 20), + label: const Text('Play'), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHorizontalList( + List items, { + bool isLarge = false, + }) { + return SliverToBoxAdapter( + child: LayoutBuilder( + builder: (context, constraints) { + // Responsive card width based on screen size + final screenWidth = constraints.maxWidth; + final cardWidth = screenWidth > 1600 + ? 220.0 + : screenWidth > 1200 + ? 200.0 + : screenWidth > 800 + ? 160.0 + : 130.0; + + // 2:3 poster aspect ratio (height is 1.5x width) + final cardHeight = cardWidth * 1.5; + // Container height = poster + padding + spacing + text + // 8px top padding + cardHeight + 4px spacing + ~26px text + 8px bottom padding + final containerHeight = cardHeight + 46; + + return SizedBox( + height: containerHeight, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: MediaCard( + client: widget.client, + item: item, + width: cardWidth, + height: cardHeight, + onRefresh: _loadContent, + userProfile: widget.userProfile, + ), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart new file mode 100644 index 000000000..048393377 --- /dev/null +++ b/lib/screens/libraries_screen.dart @@ -0,0 +1,712 @@ +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_library.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_filter.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/media_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../services/storage_service.dart'; +import '../mixins/refreshable.dart'; + +class LibrariesScreen extends StatefulWidget { + final PlexClient client; + final PlexUserProfile? userProfile; + + const LibrariesScreen({super.key, required this.client, this.userProfile}); + + @override + State createState() => _LibrariesScreenState(); +} + +class _LibrariesScreenState extends State with Refreshable { + List _libraries = []; + List _items = []; + List _filters = []; + bool _isLoadingLibraries = true; + bool _isLoadingItems = false; + String? _errorMessage; + int _selectedLibraryIndex = 0; + Map _selectedFilters = {}; + bool _isInitialLoad = true; + + @override + void initState() { + super.initState(); + _loadLibraries(); + } + + Future _loadLibraries() async { + setState(() { + _isLoadingLibraries = true; + _errorMessage = null; + }); + + try { + final libraries = await widget.client.getLibraries(); + setState(() { + _libraries = libraries; + _isLoadingLibraries = false; + }); + + if (libraries.isNotEmpty) { + // Load saved preferences + final storage = await StorageService.getInstance(); + final savedIndex = storage.getSelectedLibraryIndex(); + final savedFilters = storage.getLibraryFilters(); + + // Use saved index if valid, otherwise default to 0 + final indexToLoad = + (savedIndex != null && savedIndex < libraries.length) + ? savedIndex + : 0; + + // Restore filters BEFORE loading content + if (savedFilters.isNotEmpty) { + _selectedFilters = Map.from(savedFilters); + } + + _loadLibraryContent(indexToLoad); + } + } catch (e) { + setState(() { + _errorMessage = 'Failed to load libraries: $e'; + _isLoadingLibraries = false; + }); + } + } + + Future _loadLibraryContent(int index) async { + if (index < 0 || index >= _libraries.length) return; + + final isChangingLibrary = !_isInitialLoad && _selectedLibraryIndex != index; + + setState(() { + _selectedLibraryIndex = index; + _isLoadingItems = true; + _errorMessage = null; + // Only clear filters when explicitly changing library (not on initial load) + if (isChangingLibrary) { + _selectedFilters.clear(); + } + }); + + // Mark that initial load is complete + if (_isInitialLoad) { + _isInitialLoad = false; + } + + // Save selected library index + final storage = await StorageService.getInstance(); + await storage.saveSelectedLibraryIndex(index); + + // Clear filters in storage when changing library + if (isChangingLibrary) { + await storage.saveLibraryFilters({}); + } + + try { + // Load filters for the new library + _loadFilters(index); + + // Load content + final items = await widget.client.getLibraryContent( + _libraries[index].key, + filters: _selectedFilters, + ); + setState(() { + _items = items; + _isLoadingItems = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load library content: $e'; + _isLoadingItems = false; + }); + } + } + + Future _loadFilters(int index) async { + if (index < 0 || index >= _libraries.length) return; + + try { + final filters = await widget.client.getLibraryFilters( + _libraries[index].key, + ); + setState(() { + _filters = filters; + }); + } catch (e) { + setState(() { + _filters = []; + }); + } + } + + Future _applyFilters() async { + setState(() { + _isLoadingItems = true; + _errorMessage = null; + }); + + try { + final items = await widget.client.getLibraryContent( + _libraries[_selectedLibraryIndex].key, + filters: _selectedFilters, + ); + setState(() { + _items = items; + _isLoadingItems = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load library content: $e'; + _isLoadingItems = false; + }); + } + } + + // Public method to refresh content + @override + void refresh() { + if (_libraries.isNotEmpty) { + _applyFilters(); + } + } + + void _showFiltersBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _FiltersBottomSheet( + filters: _filters, + selectedFilters: _selectedFilters, + client: widget.client, + onFiltersChanged: (filters) async { + setState(() { + _selectedFilters.clear(); + _selectedFilters.addAll(filters); + }); + + // Save filters to storage + final storage = await StorageService.getInstance(); + await storage.saveLibraryFilters(filters); + + _applyFilters(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + DesktopSliverAppBar( + title: const Text('Libraries'), + floating: true, + pinned: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + scrolledUnderElevation: 0, + actions: [ + if (_filters.isNotEmpty) + IconButton( + icon: Badge( + label: Text('${_selectedFilters.length}'), + isLabelVisible: _selectedFilters.isNotEmpty, + child: const Icon(Icons.filter_list), + ), + onPressed: _showFiltersBottomSheet, + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _loadLibraryContent(_selectedLibraryIndex), + ), + ], + ), + if (_isLoadingLibraries) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_errorMessage != null && _libraries.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadLibraries, + child: const Text('Retry'), + ), + ], + ), + ), + ) + else if (_libraries.isEmpty) + const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.video_library_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text('No libraries found'), + ], + ), + ), + ) + else ...[ + // Library selector chips + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(_libraries.length, (index) { + final library = _libraries[index]; + final isSelected = index == _selectedLibraryIndex; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getLibraryIcon(library.type), + size: 16, + color: isSelected + ? Theme.of( + context, + ).colorScheme.onSecondaryContainer + : Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text(library.title), + ], + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + _loadLibraryContent(index); + } + }, + ), + ); + }), + ), + ), + ), + ), + + // Content grid + if (_isLoadingItems) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => + _loadLibraryContent(_selectedLibraryIndex), + child: const Text('Retry'), + ), + ], + ), + ), + ) + else if (_items.isEmpty) + const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.folder_open, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text('This library is empty'), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 190, + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = _items[index]; + return MediaCard( + client: widget.client, + item: item, + onRefresh: _applyFilters, + userProfile: widget.userProfile, + ); + }, childCount: _items.length), + ), + ), + ], + ], + ), + ); + } + + IconData _getLibraryIcon(String type) { + switch (type.toLowerCase()) { + case 'movie': + return Icons.movie; + case 'show': + return Icons.tv; + case 'artist': + return Icons.music_note; + case 'photo': + return Icons.photo; + default: + return Icons.folder; + } + } +} + +class _FiltersBottomSheet extends StatefulWidget { + final List filters; + final Map selectedFilters; + final PlexClient client; + final Function(Map) onFiltersChanged; + + const _FiltersBottomSheet({ + required this.filters, + required this.selectedFilters, + required this.client, + required this.onFiltersChanged, + }); + + @override + State<_FiltersBottomSheet> createState() => _FiltersBottomSheetState(); +} + +class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { + PlexFilter? _currentFilter; + List _filterValues = []; + bool _isLoadingValues = false; + final Map _tempSelectedFilters = {}; + final Map _filterDisplayNames = {}; // Cache for display names + late List _sortedFilters; + + @override + void initState() { + super.initState(); + _tempSelectedFilters.addAll(widget.selectedFilters); + _sortFilters(); + } + + void _sortFilters() { + // Separate boolean filters (toggles) from regular filters + final booleanFilters = widget.filters + .where((f) => f.filterType == 'boolean') + .toList(); + final regularFilters = widget.filters + .where((f) => f.filterType != 'boolean') + .toList(); + + // Combine with boolean filters first + _sortedFilters = [...booleanFilters, ...regularFilters]; + } + + bool _isBooleanFilter(PlexFilter filter) { + return filter.filterType == 'boolean'; + } + + Future _loadFilterValues(PlexFilter filter) async { + setState(() { + _currentFilter = filter; + _isLoadingValues = true; + }); + + try { + final values = await widget.client.getFilterValues(filter.key); + setState(() { + _filterValues = values; + _isLoadingValues = false; + }); + } catch (e) { + setState(() { + _filterValues = []; + _isLoadingValues = false; + }); + } + } + + void _goBack() { + setState(() { + _currentFilter = null; + _filterValues = []; + }); + } + + void _applyFilters() { + widget.onFiltersChanged(_tempSelectedFilters); + Navigator.pop(context); + } + + String _extractFilterValue(String key, String filterName) { + if (key.contains('?')) { + final queryStart = key.indexOf('?'); + final queryString = key.substring(queryStart + 1); + final params = Uri.splitQueryString(queryString); + return params[filterName] ?? key; + } else if (key.startsWith('/')) { + return key.split('/').last; + } + return key; + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + if (_currentFilter != null) { + // Show filter options view + return Column( + children: [ + // Header with back button + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _goBack, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _currentFilter!.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // Filter options list + if (_isLoadingValues) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _filterValues.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = !_tempSelectedFilters.containsKey( + _currentFilter!.filter, + ); + return ListTile( + title: const Text('All'), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters.remove( + _currentFilter!.filter, + ); + }); + _applyFilters(); + }, + ); + } + + final value = _filterValues[index - 1]; + final filterValue = _extractFilterValue( + value.key, + _currentFilter!.filter, + ); + final isSelected = + _tempSelectedFilters[_currentFilter!.filter] == + filterValue; + + return ListTile( + title: Text(value.title), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters[_currentFilter!.filter] = + filterValue; + // Cache the display name for this filter value + _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = + value.title; + }); + _applyFilters(); + }, + ); + }, + ), + ), + ], + ); + } + + // Show main filters view + return Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + const Icon(Icons.filter_list), + const SizedBox(width: 12), + const Text( + 'Filters', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const Spacer(), + if (_tempSelectedFilters.isNotEmpty) + TextButton.icon( + onPressed: () { + setState(() { + _tempSelectedFilters.clear(); + }); + _applyFilters(); + }, + icon: const Icon(Icons.clear_all), + label: const Text('Clear All'), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // All Filters (boolean toggles first, then regular filters) + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _sortedFilters.length, + itemBuilder: (context, index) { + final filter = _sortedFilters[index]; + + // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) + if (_isBooleanFilter(filter)) { + final isActive = + _tempSelectedFilters.containsKey(filter.filter) && + _tempSelectedFilters[filter.filter] == '1'; + return SwitchListTile( + value: isActive, + onChanged: (value) { + setState(() { + if (value) { + _tempSelectedFilters[filter.filter] = '1'; + } else { + _tempSelectedFilters.remove(filter.filter); + } + }); + _applyFilters(); + }, + title: Text(filter.title), + ); + } + + // Regular navigable filters - show selected value instead of checkmark + final selectedValue = _tempSelectedFilters[filter.filter]; + String? displayValue; + if (selectedValue != null) { + // Try to get the cached display name, fall back to the value itself + displayValue = + _filterDisplayNames['${filter.filter}:$selectedValue'] ?? + selectedValue; + } + + return ListTile( + title: Text(filter.title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (displayValue != null) + Flexible( + child: Text( + displayValue, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (displayValue != null) const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => _loadFilterValues(filter), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart new file mode 100644 index 000000000..d9ee0cc47 --- /dev/null +++ b/lib/screens/main_screen.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_user_profile.dart'; +import '../utils/app_logger.dart'; +import '../main.dart'; +import '../mixins/refreshable.dart'; +import 'discover_screen.dart'; +import 'libraries_screen.dart'; +import 'search_screen.dart'; + +class MainScreen extends StatefulWidget { + final PlexClient client; + final PlexUserProfile? userProfile; + + const MainScreen({super.key, required this.client, this.userProfile}); + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State with RouteAware { + int _currentIndex = 0; + + late final List _screens; + final GlobalKey> _discoverKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + _screens = [ + DiscoverScreen( + key: _discoverKey, + client: widget.client, + userProfile: widget.userProfile, + onBecameVisible: _onDiscoverBecameVisible, + ), + LibrariesScreen(client: widget.client, userProfile: widget.userProfile), + SearchScreen(client: widget.client, userProfile: widget.userProfile), + ]; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute); + } + + @override + void dispose() { + routeObserver.unsubscribe(this); + super.dispose(); + } + + @override + void didPush() { + // Called when this route has been pushed (initial navigation) + if (_currentIndex == 0) { + _onDiscoverBecameVisible(); + } + } + + @override + void didPopNext() { + // Called when returning to this route from a child route (e.g., from video player) + if (_currentIndex == 0) { + _onDiscoverBecameVisible(); + } + } + + void _onDiscoverBecameVisible() { + appLogger.d('Navigated to home'); + // Refresh content when returning to discover page + final discoverState = _discoverKey.currentState; + if (discoverState != null && discoverState is Refreshable) { + (discoverState as Refreshable).refresh(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack(index: _currentIndex, children: _screens), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: (index) { + setState(() { + _currentIndex = index; + }); + // Notify discover screen when it becomes visible via tab switch + if (index == 0) { + _onDiscoverBecameVisible(); + } + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.video_library_outlined), + selectedIcon: Icon(Icons.video_library), + label: 'Libraries', + ), + NavigationDestination( + icon: Icon(Icons.search), + selectedIcon: Icon(Icons.search), + label: 'Search', + ), + ], + ), + ); + } +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart new file mode 100644 index 000000000..f08b259ab --- /dev/null +++ b/lib/screens/media_detail_screen.dart @@ -0,0 +1,821 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../widgets/media_context_menu.dart'; +import '../utils/app_logger.dart'; +import 'season_detail_screen.dart'; +import 'video_player_screen.dart'; + +class MediaDetailScreen extends StatefulWidget { + final PlexClient client; + final PlexMetadata metadata; + final PlexUserProfile? userProfile; + + const MediaDetailScreen({ + super.key, + required this.client, + required this.metadata, + this.userProfile, + }); + + @override + State createState() => _MediaDetailScreenState(); +} + +class _MediaDetailScreenState extends State { + List _seasons = []; + bool _isLoadingSeasons = false; + PlexMetadata? _fullMetadata; + PlexMetadata? _onDeckEpisode; + bool _isLoadingMetadata = true; + + @override + void initState() { + super.initState(); + _loadFullMetadata(); + } + + Future _loadFullMetadata() async { + setState(() { + _isLoadingMetadata = true; + }); + + try { + // Fetch full metadata with clearLogo and OnDeck episode + final result = await widget.client.getMetadataWithImagesAndOnDeck( + widget.metadata.ratingKey, + ); + final metadata = result['metadata'] as PlexMetadata?; + final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; + + if (metadata != null) { + setState(() { + _fullMetadata = metadata; + _onDeckEpisode = onDeckEpisode; + _isLoadingMetadata = false; + }); + + // Load seasons if it's a show + if (metadata.type.toLowerCase() == 'show') { + _loadSeasons(); + } + return; + } + + // Fallback to passed metadata + setState(() { + _fullMetadata = widget.metadata; + _isLoadingMetadata = false; + }); + + if (widget.metadata.type.toLowerCase() == 'show') { + _loadSeasons(); + } + } catch (e) { + // Fallback to passed metadata on error + setState(() { + _fullMetadata = widget.metadata; + _isLoadingMetadata = false; + }); + + if (widget.metadata.type.toLowerCase() == 'show') { + _loadSeasons(); + } + } + } + + Future _loadSeasons() async { + setState(() { + _isLoadingSeasons = true; + }); + + try { + final seasons = await widget.client.getChildren( + widget.metadata.ratingKey, + ); + setState(() { + _seasons = seasons; + _isLoadingSeasons = false; + }); + } catch (e) { + setState(() { + _isLoadingSeasons = false; + }); + } + } + + Future _playFirstEpisode() async { + try { + // If seasons aren't loaded yet, wait for them or load them + if (_seasons.isEmpty && !_isLoadingSeasons) { + await _loadSeasons(); + } + + // Wait for seasons to finish loading if they're currently loading + while (_isLoadingSeasons) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (_seasons.isEmpty) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('No seasons found'))); + } + return; + } + + // Get the first season (usually Season 1, but could be Season 0 for specials) + final firstSeason = _seasons.first; + + // Get episodes of the first season + final episodes = await widget.client.getChildren(firstSeason.ratingKey); + + if (episodes.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No episodes found in first season')), + ); + } + return; + } + + // Play the first episode + final firstEpisode = episodes.first; + if (mounted) { + appLogger.d('Playing first episode: ${firstEpisode.title}'); + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: firstEpisode, + userProfile: widget.userProfile, + ), + ), + ); + appLogger.d('Returned from playback, refreshing metadata'); + // Refresh metadata when returning from video player + _loadFullMetadata(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading first episode: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + // Use full metadata if loaded, otherwise use passed metadata + final metadata = _fullMetadata ?? widget.metadata; + final isShow = metadata.type.toLowerCase() == 'show'; + + // Show loading state while fetching full metadata + if (_isLoadingMetadata) { + return Scaffold( + appBar: AppBar(), + body: const Center(child: CircularProgressIndicator()), + ); + } + + // Determine header height based on screen size + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 600; + final headerHeight = isDesktop ? size.height * 0.6 : size.height * 0.4; + + return Scaffold( + body: CustomScrollView( + slivers: [ + // Hero header with background art + DesktopSliverAppBar( + expandedHeight: headerHeight, + pinned: true, + leading: SafeArea( + child: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + ), + ), + ), + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + // Background Art + if (metadata.art != null) + CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl(metadata.art), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ) + else + Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + + // Gradient overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0.95), + ], + stops: const [0.3, 0.7, 1.0], + ), + ), + ), + + // Content at bottom + Positioned( + bottom: 16, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Clear logo or title + if (metadata.clearLogo != null) + SizedBox( + height: 120, + width: 400, + child: CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + metadata.clearLogo, + ), + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + placeholder: (context, url) => Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white.withValues( + alpha: 0.3, + ), + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + errorWidget: (context, url, error) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black + .withValues(alpha: 0.5), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), + ) + else + Text( + metadata.title, + style: Theme.of(context).textTheme.displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + + // Metadata chips + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (metadata.year != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${metadata.year}', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + if (metadata.contentRating != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + metadata.contentRating!, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + if (metadata.duration != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + _formatDuration(metadata.duration!), + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + + // Main content + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Action buttons + Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: FilledButton.icon( + onPressed: () async { + // For TV shows, play the OnDeck episode if available + // Otherwise, play the first episode of the first season + if (metadata.type.toLowerCase() == 'show') { + if (_onDeckEpisode != null) { + appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}'); + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: _onDeckEpisode!, + userProfile: widget.userProfile, + ), + ), + ); + appLogger.d('Returned from playback, refreshing metadata'); + // Refresh metadata when returning from video player + _loadFullMetadata(); + } else { + // No on deck episode, fetch first episode of first season + await _playFirstEpisode(); + } + } else { + appLogger.d('Playing: ${metadata.title}'); + // For movies or episodes, play directly + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: metadata, + userProfile: widget.userProfile, + ), + ), + ); + appLogger.d('Returned from playback, refreshing metadata'); + // Refresh metadata when returning from video player + _loadFullMetadata(); + } + }, + icon: const Icon(Icons.play_arrow, size: 20), + label: Text( + _getPlayButtonLabel(metadata), + style: const TextStyle(fontSize: 16), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + IconButton.filledTonal( + onPressed: () async { + try { + await widget.client.markAsWatched( + metadata.ratingKey, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Marked as watched'), + ), + ); + // Refresh metadata to update UI + _loadFullMetadata(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + }, + icon: const Icon(Icons.check), + tooltip: 'Mark as watched', + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + ), + ), + const SizedBox(width: 12), + IconButton.filledTonal( + onPressed: () async { + try { + await widget.client.markAsUnwatched( + metadata.ratingKey, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Marked as unwatched'), + ), + ); + // Refresh metadata to update UI + _loadFullMetadata(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + }, + icon: const Icon(Icons.remove_done), + tooltip: 'Mark as unwatched', + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Summary + if (metadata.summary != null) ...[ + Text( + 'Overview', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + metadata.summary!, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(height: 1.6), + ), + const SizedBox(height: 24), + ], + + // Seasons (for TV shows) + if (isShow) ...[ + Text( + 'Seasons', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + if (_isLoadingSeasons) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ) + else if (_seasons.isEmpty) + Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + 'No seasons found', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Colors.grey), + ), + ), + ) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: _seasons.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final season = _seasons[index]; + return _buildSeasonCard(season); + }, + ), + const SizedBox(height: 24), + ], + + // Additional info + if (metadata.studio != null) ...[ + _buildInfoRow('Studio', metadata.studio!), + const SizedBox(height: 12), + ], + if (metadata.contentRating != null) ...[ + _buildInfoRow('Rating', metadata.contentRating!), + const SizedBox(height: 12), + ], + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildSeasonCard(PlexMetadata season) { + return Card( + clipBehavior: Clip.antiAlias, + child: MediaContextMenu( + client: widget.client, + metadata: season, + onRefresh: _loadFullMetadata, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + SeasonDetailScreen(client: widget.client, season: season), + ), + ); + }, + child: InkWell( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Season poster + if (season.thumb != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl(season.thumb), + width: 80, + height: 120, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ), + ) + else + Container( + width: 80, + height: 120, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.movie, size: 32), + ), + const SizedBox(width: 16), + + // Season info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + season.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + if (season.leafCount != null) + Text( + '${season.leafCount} episodes', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 8), + if (season.viewedLeafCount != null && + season.leafCount != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: + season.viewedLeafCount! / season.leafCount!, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + const SizedBox(height: 4), + Text( + '${season.viewedLeafCount}/${season.leafCount} watched', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Colors.grey), + ), + ], + ), + ], + ), + ), + + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text(value, style: Theme.of(context).textTheme.bodyLarge), + ), + ], + ); + } + + String _formatDuration(int milliseconds) { + final duration = Duration(milliseconds: milliseconds); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else { + return '${minutes}m'; + } + } + + String _getPlayButtonLabel(PlexMetadata metadata) { + // For TV shows + if (metadata.type.toLowerCase() == 'show') { + if (_onDeckEpisode != null) { + final episode = _onDeckEpisode!; + final seasonNum = episode.parentIndex ?? 0; + final episodeNum = episode.index ?? 0; + + // Check if episode has been partially watched (viewOffset > 0) + if (episode.viewOffset != null && episode.viewOffset! > 0) { + return 'Resume S$seasonNum, E$episodeNum'; + } else { + return 'Play S$seasonNum, E$episodeNum'; + } + } else { + // No on deck episode, will play first episode + return 'Play S1, E1'; + } + } + + // For movies or episodes, check if partially watched + if (metadata.viewOffset != null && metadata.viewOffset! > 0) { + return 'Resume'; + } + + return 'Play'; + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart new file mode 100644 index 000000000..8e5769217 --- /dev/null +++ b/lib/screens/search_screen.dart @@ -0,0 +1,212 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/media_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../mixins/refreshable.dart'; + +class SearchScreen extends StatefulWidget { + final PlexClient client; + final PlexUserProfile? userProfile; + + const SearchScreen({super.key, required this.client, this.userProfile}); + + @override + State createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State with Refreshable { + final _searchController = TextEditingController(); + List _searchResults = []; + bool _isSearching = false; + bool _hasSearched = false; + Timer? _debounceTimer; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged() { + // Cancel previous timer + _debounceTimer?.cancel(); + + final query = _searchController.text; + + if (query.trim().isEmpty) { + setState(() { + _searchResults = []; + _hasSearched = false; + _isSearching = false; + }); + return; + } + + // Start new timer + _debounceTimer = Timer(const Duration(milliseconds: 500), () { + _performSearch(query); + }); + } + + Future _performSearch(String query) async { + if (query.trim().isEmpty) { + setState(() { + _searchResults = []; + _hasSearched = false; + }); + return; + } + + setState(() { + _isSearching = true; + _hasSearched = true; + }); + + try { + final results = await widget.client.search(query); + if (mounted) { + setState(() { + _searchResults = results; + _isSearching = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isSearching = false; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Search failed: $e'))); + } + } + } + + @override + void refresh() { + // Re-run the current search if there is one + if (_searchController.text.isNotEmpty) { + _performSearch(_searchController.text); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + DesktopSliverAppBar(title: const Text('Search'), floating: true), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: SearchBar( + controller: _searchController, + hintText: 'Search movies, shows, music...', + leading: const Icon(Icons.search), + trailing: [ + if (_searchController.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + // State update handled by listener + }, + ), + ], + autoFocus: false, + ), + ), + ), + if (_isSearching) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (!_hasSearched) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search, size: 80, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Search your media', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + 'Enter a title, actor, or keyword', + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + ) + else if (_searchResults.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 80, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'No results found', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + 'Try a different search term', + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 180, + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = _searchResults[index]; + return MediaCard( + client: widget.client, + item: item, + onRefresh: refresh, + userProfile: widget.userProfile, + ); + }, childCount: _searchResults.length), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart new file mode 100644 index 000000000..6c52513af --- /dev/null +++ b/lib/screens/season_detail_screen.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/desktop_app_bar.dart'; +import 'video_player_screen.dart'; + +class SeasonDetailScreen extends StatefulWidget { + final PlexClient client; + final PlexMetadata season; + final PlexUserProfile? userProfile; + + const SeasonDetailScreen({ + super.key, + required this.client, + required this.season, + this.userProfile, + }); + + @override + State createState() => _SeasonDetailScreenState(); +} + +class _SeasonDetailScreenState extends State { + List _episodes = []; + bool _isLoadingEpisodes = false; + + @override + void initState() { + super.initState(); + _loadEpisodes(); + } + + Future _loadEpisodes() async { + setState(() { + _isLoadingEpisodes = true; + }); + + try { + final episodes = await widget.client.getChildren(widget.season.ratingKey); + setState(() { + _episodes = episodes; + _isLoadingEpisodes = false; + }); + } catch (e) { + setState(() { + _isLoadingEpisodes = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + DesktopSliverAppBar( + title: Text(widget.season.title), + pinned: true, + leading: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + ), + ), + ), + if (_isLoadingEpisodes) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_episodes.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.movie_outlined, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text( + 'No episodes found', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: Colors.grey), + ), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index.isOdd) { + return const SizedBox(height: 12); + } + final episodeIndex = index ~/ 2; + final episode = _episodes[episodeIndex]; + return _buildEpisodeCard(episode); + }, childCount: _episodes.length * 2 - 1), + ), + ), + ], + ), + ); + } + + Widget _buildEpisodeCard(PlexMetadata episode) { + final hasProgress = + episode.viewOffset != null && + episode.duration != null && + episode.viewOffset! > 0; + final progress = hasProgress + ? episode.viewOffset! / episode.duration! + : 0.0; + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: episode, + userProfile: widget.userProfile, + ), + ), + ); + // Refresh episodes when returning from video player + _loadEpisodes(); + }, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode thumbnail (16:9 aspect ratio, fixed width) + SizedBox( + width: 160, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: AspectRatio( + aspectRatio: 16 / 9, + child: episode.thumb != null + ? CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + episode.thumb, + ), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ) + : Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ), + ), + + // Play overlay + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.2), + ], + ), + ), + child: Center( + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 20, + ), + ), + ), + ), + ), + + // Watched indicator + if (episode.isWatched) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 12, + ), + ), + ), + + // Progress bar at bottom + if (hasProgress && !episode.isWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(6), + bottomRight: Radius.circular(6), + ), + child: LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.withValues(alpha: 0.3), + minHeight: 3, + ), + ), + ), + + // Duration badge + if (episode.duration != null) + Positioned( + bottom: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + _formatDuration(episode.duration!), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode number and title + Row( + children: [ + if (episode.index != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + 'E${episode.index}', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + episode.title, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + + // Summary + if (episode.summary != null && + episode.summary!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + episode.summary!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + height: 1.3, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + String _formatDuration(int milliseconds) { + final duration = Duration(milliseconds: milliseconds); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '${minutes}:${seconds.toString().padLeft(2, '0')}'; + } + } +} diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart new file mode 100644 index 000000000..1a254fb0b --- /dev/null +++ b/lib/screens/server_selection_screen.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import '../services/plex_auth_service.dart'; +import '../services/storage_service.dart'; +import '../client/plex_client.dart'; +import '../config/plex_config.dart'; +import '../widgets/server_list_tile.dart'; +import 'main_screen.dart'; + +class ServerSelectionScreen extends StatefulWidget { + final PlexAuthService authService; + final String plexToken; + + const ServerSelectionScreen({ + super.key, + required this.authService, + required this.plexToken, + }); + + @override + State createState() => _ServerSelectionScreenState(); +} + +class _ServerSelectionScreenState extends State { + List? _servers; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadServers(); + } + + Future _loadServers() async { + try { + final servers = await widget.authService.fetchServers(widget.plexToken); + setState(() { + _servers = servers; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load servers: $e'; + _isLoading = false; + }); + } + } + + Future _selectServer(PlexServer server) async { + // Show loading dialog + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Testing connections...'), + ], + ), + ), + ); + } + + // Test connections to find best working one + final connection = await server.findBestWorkingConnection(); + + // Close loading dialog + if (mounted) { + Navigator.pop(context); + } + + if (connection == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No working connections found for this server'), + ), + ); + } + return; + } + + // Store server information + final storage = await StorageService.getInstance(); + await storage.saveServerData(server.toJson()); + await storage.saveServerUrl(connection.uri); + await storage.saveServerAccessToken(server.accessToken); + await storage.savePlexToken(widget.plexToken); + + // Get client identifier + final clientId = + storage.getClientIdentifier() ?? widget.authService.clientIdentifier; + + // Create client and navigate to main app + final config = PlexConfig( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientId, + ); + final client = PlexClient(config); + + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => MainScreen(client: client)), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Select Server')), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadServers, + child: const Text('Retry'), + ), + ], + ), + ) + : _servers == null || _servers!.isEmpty + ? const Center(child: Text('No servers found')) + : ListView.builder( + itemCount: _servers!.length, + padding: const EdgeInsets.all(16), + itemBuilder: (context, index) { + final server = _servers![index]; + return Card( + child: ServerListTile( + server: server, + onTap: () => _selectServer(server), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart new file mode 100644 index 000000000..7189ae429 --- /dev/null +++ b/lib/screens/video_player_screen.dart @@ -0,0 +1,784 @@ +import 'dart:async'; +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 '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/plex_video_controls.dart'; +import '../utils/language_codes.dart'; +import '../utils/app_logger.dart'; + +class VideoPlayerScreen extends StatefulWidget { + final PlexClient client; + final PlexMetadata metadata; + final AudioTrack? preferredAudioTrack; + final SubtitleTrack? preferredSubtitleTrack; + final PlexUserProfile? userProfile; + + const VideoPlayerScreen({ + super.key, + required this.client, + required this.metadata, + this.preferredAudioTrack, + this.preferredSubtitleTrack, + this.userProfile, + }); + + @override + State createState() => _VideoPlayerScreenState(); +} + +class _VideoPlayerScreenState extends State { + late final Player player; + late final VideoController controller; + Timer? _progressTimer; + PlexMetadata? _nextEpisode; + PlexMetadata? _previousEpisode; + bool _isLoadingNext = false; + bool _showPlayNextDialog = false; + + @override + void initState() { + super.initState(); + + appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}'); + if (widget.userProfile != null) { + appLogger.d('Using user profile for track selection'); + } + if (widget.preferredAudioTrack != null) { + appLogger.d( + 'Preferred audio track: ${widget.preferredAudioTrack!.title ?? widget.preferredAudioTrack!.id} (${widget.preferredAudioTrack!.language ?? "unknown"})', + ); + } + if (widget.preferredSubtitleTrack != null) { + final subtitleDesc = widget.preferredSubtitleTrack!.id == "no" + ? "OFF" + : "${widget.preferredSubtitleTrack!.title ?? widget.preferredSubtitleTrack!.id} (${widget.preferredSubtitleTrack!.language ?? "unknown"})"; + appLogger.d('Preferred subtitle track: $subtitleDesc'); + } + + // Create player and controller + player = Player(configuration: PlayerConfiguration(libass: true)); + controller = VideoController(player); + + // Get the video URL and start playback + _startPlayback(); + + // Set fullscreen mode and landscape orientation + _setLandscapeOrientation(); + + // Listen to playback state changes + player.stream.playing.listen(_onPlayingStateChanged); + + // Listen to completion + player.stream.completed.listen(_onVideoCompleted); + + // Start periodic progress updates + _startProgressTracking(); + + // Load next/previous episodes + _loadAdjacentEpisodes(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Ensure landscape orientation is set even after navigation + _setLandscapeOrientation(); + } + + void _setLandscapeOrientation() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + + Future _loadAdjacentEpisodes() async { + if (widget.metadata.type.toLowerCase() != 'episode') { + return; + } + + try { + final next = await widget.client.getNextEpisode(widget.metadata); + final previous = await widget.client.getPreviousEpisode(widget.metadata); + + if (mounted) { + setState(() { + _nextEpisode = next; + _previousEpisode = previous; + }); + } + } catch (e) { + // Silently handle errors + } + } + + Future _startPlayback() async { + try { + // Get the direct file URL from the server + final videoUrl = await widget.client.getVideoUrl( + widget.metadata.ratingKey, + ); + + if (videoUrl != null) { + // Open video without auto-playing + await player.open(Media(videoUrl), play: false); + + // Wait for media to be ready (duration > 0) + int attempts = 0; + while (player.state.duration.inMilliseconds == 0 && attempts < 50) { + await Future.delayed(const Duration(milliseconds: 100)); + attempts++; + } + + // Set up playback position if resuming + if (widget.metadata.viewOffset != null && + widget.metadata.viewOffset! > 0) { + final resumePosition = Duration( + milliseconds: widget.metadata.viewOffset!, + ); + await player.seek(resumePosition); + } + + // Start playback after seeking + await player.play(); + + // Wait for tracks to be loaded, then apply preferred tracks + _waitForTracksAndApply(); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not find video file')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + @override + void dispose() { + // Stop progress tracking + _progressTimer?.cancel(); + + // Send final stopped state + _sendProgress('stopped'); + + // Restore system UI + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + // Restore portrait-only orientation + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + player.dispose(); + super.dispose(); + } + + void _startProgressTracking() { + // Send progress update every 10 seconds + _progressTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (player.state.playing) { + _sendProgress('playing'); + } + }); + } + + AudioTrack? _findBestAudioMatch( + List availableTracks, + AudioTrack preferred, + ) { + if (availableTracks.isEmpty) return null; + + // Filter out auto and no tracks + final validTracks = availableTracks + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + if (validTracks.isEmpty) return null; + + // Try to match: index, title, and language + for (var track in validTracks) { + if (track.id == preferred.id && + track.title == preferred.title && + track.language == preferred.language) { + return track; + } + } + + // Try to match: title and language + for (var track in validTracks) { + if (track.title == preferred.title && + track.language == preferred.language) { + return track; + } + } + + // Try to match: language only + for (var track in validTracks) { + if (track.language == preferred.language) { + return track; + } + } + + return null; + } + + AudioTrack? _findAudioTrackByProfile( + List availableTracks, + PlexUserProfile profile, + ) { + appLogger.d('Audio track selection using user profile'); + appLogger.d( + 'Profile settings - autoSelectAudio: ${profile.autoSelectAudio}, defaultAudioLanguage: ${profile.defaultAudioLanguage}', + ); + + if (availableTracks.isEmpty || !profile.autoSelectAudio) { + appLogger.d( + 'Cannot use profile: ${availableTracks.isEmpty ? "No tracks available" : "autoSelectAudio is false"}', + ); + return null; + } + + final preferredLanguage = profile.defaultAudioLanguage; + if (preferredLanguage == null || preferredLanguage.isEmpty) { + appLogger.d('Cannot use profile: No defaultAudioLanguage specified'); + return null; + } + + // Get all possible language code variations (e.g., "en" → ["en", "eng"]) + final languageVariations = LanguageCodes.getVariations(preferredLanguage); + appLogger.d( + 'Checking language variations: ${languageVariations.join(", ")}', + ); + + // Try to find track matching any language variation + for (var track in availableTracks) { + final trackLang = track.language?.toLowerCase(); + if (trackLang != null && languageVariations.contains(trackLang)) { + appLogger.d( + 'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + ); + return track; + } + } + + appLogger.d( + 'No audio track found matching profile language "$preferredLanguage" or its variations', + ); + return null; + } + + SubtitleTrack? _findBestSubtitleMatch( + List availableTracks, + SubtitleTrack preferred, + ) { + // If preferred is "no", return no subtitles + if (preferred.id == 'no') { + return SubtitleTrack.no(); + } + + if (availableTracks.isEmpty) return null; + + // Filter out auto and no tracks + final validTracks = availableTracks + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + if (validTracks.isEmpty) return null; + + // Try to match: index, title, and language + for (var track in validTracks) { + if (track.id == preferred.id && + track.title == preferred.title && + track.language == preferred.language) { + return track; + } + } + + // Try to match: title and language + for (var track in validTracks) { + if (track.title == preferred.title && + track.language == preferred.language) { + return track; + } + } + + // Try to match: language only + for (var track in validTracks) { + if (track.language == preferred.language) { + return track; + } + } + + return null; + } + + SubtitleTrack? _findSubtitleTrackByProfile( + List availableTracks, + PlexUserProfile profile, + ) { + appLogger.d('Subtitle track selection using user profile'); + appLogger.d( + 'Profile settings - autoSelectSubtitle: ${profile.autoSelectSubtitle}, defaultSubtitleLanguage: ${profile.defaultSubtitleLanguage}, defaultSubtitleForced: ${profile.defaultSubtitleForced}', + ); + + if (availableTracks.isEmpty) { + appLogger.d('Cannot use profile: No subtitle tracks available'); + return null; + } + + // If autoSelectSubtitle is 0, don't select any subtitle + if (!profile.shouldAutoSelectSubtitle) { + appLogger.d( + 'Profile specifies no auto-select (autoSelectSubtitle=0) - Subtitles OFF', + ); + return SubtitleTrack.no(); + } + + final preferredLanguage = profile.defaultSubtitleLanguage; + if (preferredLanguage == null || preferredLanguage.isEmpty) { + appLogger.d('Cannot use profile: No defaultSubtitleLanguage specified'); + return null; + } + + // Get all possible language code variations (e.g., "en" → ["en", "eng"]) + final languageVariations = LanguageCodes.getVariations(preferredLanguage); + appLogger.d( + 'Checking language variations: ${languageVariations.join(", ")}', + ); + + // If defaultSubtitleForced is 1, prefer forced subtitles + if (profile.preferForcedSubtitles) { + appLogger.d('Profile prefers forced subtitles (defaultSubtitleForced=1)'); + // Try to find forced subtitle in preferred language + for (var track in availableTracks) { + final trackLang = track.language?.toLowerCase(); + if (trackLang != null && + languageVariations.contains(trackLang) && + track.title?.toLowerCase().contains('forced') == true) { + appLogger.d( + 'Found forced subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + ); + return track; + } + } + appLogger.d( + 'No forced subtitle found in "$preferredLanguage" or its variations, trying regular subtitles', + ); + } + + // Try to find regular subtitle in preferred language + for (var track in availableTracks) { + final trackLang = track.language?.toLowerCase(); + if (trackLang != null && languageVariations.contains(trackLang)) { + appLogger.d( + 'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + ); + return track; + } + } + + appLogger.d( + 'No subtitle track found matching profile language "$preferredLanguage" or its variations', + ); + return null; + } + + void _waitForTracksAndApply() async { + // Helper function to process tracks + Future processTracks(Tracks tracks) async { + appLogger.d('Starting track selection process'); + + // Get real tracks (excluding auto and no) + final realAudioTracks = tracks.audio + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + final realSubtitleTracks = tracks.subtitle + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + + appLogger.d('Available audio tracks: ${realAudioTracks.length}'); + for (var track in realAudioTracks) { + appLogger.d( + ' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}', + ); + } + appLogger.d('Available subtitle tracks: ${realSubtitleTracks.length}'); + for (var track in realSubtitleTracks) { + appLogger.d( + ' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}', + ); + } + + // Select audio track with priority: preferred > user profile > default > first + appLogger.d('Audio track selection'); + if (realAudioTracks.isNotEmpty) { + AudioTrack? trackToSelect; + + // Priority 1: Try to match preferred track from navigation + if (widget.preferredAudioTrack != null) { + appLogger.d('Priority 1: Checking preferred track from navigation'); + appLogger.d( + ' Preferred: ${widget.preferredAudioTrack!.title ?? "Track ${widget.preferredAudioTrack!.id}"} (${widget.preferredAudioTrack!.language ?? "unknown"})', + ); + trackToSelect = _findBestAudioMatch( + realAudioTracks, + widget.preferredAudioTrack!, + ); + if (trackToSelect != null) { + appLogger.d(' Matched preferred track'); + } else { + appLogger.d(' No match found for preferred track'); + } + } else { + appLogger.d('Priority 1: No preferred track from navigation'); + } + + // Priority 2: If no preferred track matched, try user profile preferences + if (trackToSelect == null && widget.userProfile != null) { + appLogger.d('Priority 2: Checking user profile preferences'); + trackToSelect = _findAudioTrackByProfile( + realAudioTracks, + widget.userProfile!, + ); + } else if (trackToSelect == null) { + appLogger.d('Priority 2: No user profile available'); + } + + // Priority 3: If no match, use default or first track + if (trackToSelect == null) { + appLogger.d('Priority 3: Using default or first available track'); + trackToSelect = realAudioTracks.firstWhere( + (t) => t.isDefault == true, + orElse: () => realAudioTracks.first, + ); + final isDefault = trackToSelect.isDefault == true; + appLogger.d( + ' Selected ${isDefault ? "default" : "first"} track: ${trackToSelect.title ?? "Track ${trackToSelect.id}"} (${trackToSelect.language ?? "unknown"})', + ); + } + + appLogger.i( + 'Final audio selection: ${trackToSelect.title ?? "Track ${trackToSelect.id}"} (${trackToSelect.language ?? "unknown"})', + ); + player.setAudioTrack(trackToSelect); + } else { + appLogger.d('No audio tracks available'); + } + + // Select subtitle track with priority: preferred > user profile > default > off + appLogger.d('Subtitle track selection'); + SubtitleTrack? subtitleToSelect; + + // Priority 1: Try preferred track from navigation (always wins) + if (widget.preferredSubtitleTrack != null) { + appLogger.d('Priority 1: Checking preferred track from navigation'); + if (widget.preferredSubtitleTrack!.id == 'no') { + appLogger.d(' Preferred: OFF'); + subtitleToSelect = SubtitleTrack.no(); + appLogger.d(' Using preferred setting: Subtitles OFF'); + } else if (realSubtitleTracks.isNotEmpty) { + appLogger.d( + ' Preferred: ${widget.preferredSubtitleTrack!.title ?? "Track ${widget.preferredSubtitleTrack!.id}"} (${widget.preferredSubtitleTrack!.language ?? "unknown"})', + ); + subtitleToSelect = _findBestSubtitleMatch( + realSubtitleTracks, + widget.preferredSubtitleTrack!, + ); + if (subtitleToSelect != null) { + appLogger.d(' Matched preferred track'); + } else { + appLogger.d(' No match found for preferred track'); + } + } + } else { + appLogger.d('Priority 1: No preferred track from navigation'); + } + + // Priority 2: If no preferred match, apply user profile preferences + if (subtitleToSelect == null && + widget.userProfile != null && + realSubtitleTracks.isNotEmpty) { + appLogger.d('Priority 2: Checking user profile preferences'); + subtitleToSelect = _findSubtitleTrackByProfile( + realSubtitleTracks, + widget.userProfile!, + ); + } else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { + appLogger.d('Priority 2: No user profile available'); + } + + // Priority 3: If no profile match, check for default subtitle + if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { + appLogger.d('Priority 3: Checking for default subtitle track'); + final defaultTrackIndex = realSubtitleTracks.indexWhere( + (t) => t.isDefault == true, + ); + if (defaultTrackIndex != -1) { + subtitleToSelect = realSubtitleTracks[defaultTrackIndex]; + appLogger.d( + ' Found default track: ${subtitleToSelect.title ?? "Track ${subtitleToSelect.id}"} (${subtitleToSelect.language ?? "unknown"})', + ); + } else { + appLogger.d(' No default subtitle track found'); + } + } + + // If still no subtitle selected, turn off + if (subtitleToSelect == null) { + appLogger.d('Priority 4: No subtitle selected - Subtitles OFF'); + subtitleToSelect = SubtitleTrack.no(); + } + + final finalSubtitle = subtitleToSelect.id == 'no' + ? 'OFF' + : '${subtitleToSelect.title ?? "Track ${subtitleToSelect.id}"} (${subtitleToSelect.language ?? "unknown"})'; + appLogger.i('Final subtitle selection: $finalSubtitle'); + player.setSubtitleTrack(subtitleToSelect); + + appLogger.d('Track selection complete'); + } + + // Check if tracks are already available in current state + final currentTracks = player.state.tracks; + if (currentTracks.audio.isNotEmpty || currentTracks.subtitle.isNotEmpty) { + await processTracks(currentTracks); + return; + } + + // If not, listen to tracks stream for when they become available + bool applied = false; + final subscription = player.stream.tracks.listen((tracks) async { + // Check if tracks are loaded (have at least one track) and not yet applied + if (!applied && (tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty)) { + applied = true; + await processTracks(tracks); + } + }); + + // Cancel subscription after timeout + Future.delayed(const Duration(seconds: 5), () { + subscription.cancel(); + }); + } + + void _onPlayingStateChanged(bool isPlaying) { + // Send timeline update when playback state changes + _sendProgress(isPlaying ? 'playing' : 'paused'); + } + + void _sendProgress(String state) { + final position = player.state.position.inMilliseconds; + final duration = player.state.duration.inMilliseconds; + + if (duration > 0) { + widget.client + .updateProgress( + widget.metadata.ratingKey, + time: position, + state: state, + duration: duration, + ) + .catchError((error) { + // Silently handle errors - don't interrupt playback + }); + } + } + + void _onVideoCompleted(bool completed) { + if (completed && _nextEpisode != null && !_showPlayNextDialog) { + setState(() { + _showPlayNextDialog = true; + }); + } + } + + Future _playNext() async { + if (_nextEpisode == null || _isLoadingNext) return; + + setState(() { + _isLoadingNext = true; + _showPlayNextDialog = false; + }); + + // Capture current track selection BEFORE pausing + final currentAudioTrack = player.state.track.audio; + final currentSubtitleTrack = player.state.track.subtitle; + + // Pause and stop current playback + player.pause(); + _progressTimer?.cancel(); + _sendProgress('stopped'); + + // Navigate to the next episode using pushReplacement to destroy current player + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: _nextEpisode!, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: currentSubtitleTrack, + userProfile: widget.userProfile, + ), + ), + ); + } + } + + Future _playPrevious() async { + if (_previousEpisode == null) return; + + // Capture current track selection BEFORE pausing + final currentAudioTrack = player.state.track.audio; + final currentSubtitleTrack = player.state.track.subtitle; + + // Pause and stop current playback + player.pause(); + _progressTimer?.cancel(); + _sendProgress('stopped'); + + // Navigate to the previous episode using pushReplacement to destroy current player + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: _previousEpisode!, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: currentSubtitleTrack, + userProfile: widget.userProfile, + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: true, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // Video player + Center( + child: Video( + controller: controller, + controls: (state) => plexVideoControlsBuilder( + player, + widget.client, + widget.metadata, + onNext: _nextEpisode != null ? _playNext : null, + onPrevious: _previousEpisode != null ? _playPrevious : null, + ), + ), + ), + // Play Next Dialog + if (_showPlayNextDialog && _nextEpisode != null) + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.8), + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 32), + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_circle_outline, + size: 64, + color: Colors.white, + ), + const SizedBox(height: 24), + const Text( + 'Up Next', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + _nextEpisode!.grandparentTitle ?? + _nextEpisode!.title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + if (_nextEpisode!.parentIndex != null && + _nextEpisode!.index != null) + Text( + 'S${_nextEpisode!.parentIndex} · E${_nextEpisode!.index} · ${_nextEpisode!.title}', + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton( + onPressed: () { + setState(() { + _showPlayNextDialog = false; + }); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + child: const Text('Cancel'), + ), + const SizedBox(width: 16), + FilledButton( + onPressed: _playNext, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + child: const Text('Play Now'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/fullscreen_state_manager.dart b/lib/services/fullscreen_state_manager.dart new file mode 100644 index 000000000..77533cc4c --- /dev/null +++ b/lib/services/fullscreen_state_manager.dart @@ -0,0 +1,67 @@ +import 'dart:io' show Platform; +import 'package:flutter/foundation.dart'; +import 'package:window_manager/window_manager.dart'; + +/// Global manager for tracking fullscreen state across the app +class FullscreenStateManager extends ChangeNotifier with WindowListener { + static final FullscreenStateManager _instance = + FullscreenStateManager._internal(); + + factory FullscreenStateManager() => _instance; + + FullscreenStateManager._internal(); + + bool _isFullscreen = false; + bool _isListening = false; + + bool get isFullscreen => _isFullscreen; + + /// Manually set fullscreen state (called by NSWindowDelegate callbacks on macOS) + void setFullscreen(bool value) { + if (_isFullscreen != value) { + _isFullscreen = value; + notifyListeners(); + } + } + + /// Start monitoring fullscreen state + void startMonitoring() { + if (!_shouldMonitor() || _isListening) return; + + // Use window_manager listener for Windows/Linux + // macOS uses NSWindowDelegate callbacks instead (see FullscreenWindowDelegate) + if (!Platform.isMacOS) { + windowManager.addListener(this); + _isListening = true; + } + } + + /// Stop monitoring fullscreen state + void stopMonitoring() { + if (_isListening) { + windowManager.removeListener(this); + _isListening = false; + } + } + + bool _shouldMonitor() { + return Platform.isMacOS || Platform.isWindows || Platform.isLinux; + } + + // WindowListener callbacks for Windows/Linux + @override + void onWindowEnterFullScreen() { + setFullscreen(true); + } + + @override + void onWindowLeaveFullScreen() { + setFullscreen(false); + } + + @override + void dispose() { + stopMonitoring(); + super.dispose(); + } +} diff --git a/lib/services/fullscreen_window_delegate.dart b/lib/services/fullscreen_window_delegate.dart new file mode 100644 index 000000000..cb2fcee9e --- /dev/null +++ b/lib/services/fullscreen_window_delegate.dart @@ -0,0 +1,70 @@ +import 'package:macos_window_utils/macos_window_utils.dart'; +import 'package:macos_window_utils/macos/ns_window_delegate.dart'; +import 'package:macos_window_utils/macos/ns_window_button_type.dart'; +import 'package:flutter/material.dart' show Offset; +import 'fullscreen_state_manager.dart'; + +/// Custom window delegate that manages titlebar configuration during fullscreen transitions +class FullscreenWindowDelegate extends NSWindowDelegate { + static const double _customButtonY = 21.0; + + @override + void windowWillEnterFullScreen() { + // Notify global state manager + FullscreenStateManager().setFullscreen(true); + + // Remove toolbar and restore default titlebar before entering fullscreen + _prepareForFullscreen(); + } + + @override + void windowWillExitFullScreen() { + // Hide title and make transparent immediately (safe to do before transition) + WindowManipulator.hideTitle(); + WindowManipulator.makeTitlebarTransparent(); + } + + @override + void windowDidExitFullScreen() { + // Notify global state manager + FullscreenStateManager().setFullscreen(false); + + // Add toolbar and reposition traffic lights after transition completes + WindowManipulator.addToolbar(); + + // Restore custom traffic light positions + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.closeButton, + offset: const Offset(20, _customButtonY), + ); + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.miniaturizeButton, + offset: const Offset(40, _customButtonY), + ); + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.zoomButton, + offset: const Offset(60, _customButtonY), + ); + } + + /// Prepare titlebar for fullscreen mode + void _prepareForFullscreen() { + WindowManipulator.removeToolbar(); + WindowManipulator.showTitle(); + WindowManipulator.makeTitlebarOpaque(); + + // Set traffic lights to standard fullscreen positions (null = default) + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.closeButton, + offset: null, + ); + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.miniaturizeButton, + offset: null, + ); + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.zoomButton, + offset: null, + ); + } +} diff --git a/lib/services/macos_titlebar_service.dart b/lib/services/macos_titlebar_service.dart new file mode 100644 index 000000000..3ee66cfab --- /dev/null +++ b/lib/services/macos_titlebar_service.dart @@ -0,0 +1,61 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.dart' show Offset; +import 'package:macos_window_utils/macos_window_utils.dart'; +import 'package:macos_window_utils/macos/ns_window_button_type.dart'; +import 'fullscreen_window_delegate.dart'; + +/// Service to manage macOS titlebar configuration +class MacOSTitlebarService { + // Standard button Y position when using custom toolbar + static const double _customButtonY = 21.0; + + /// Initialize the custom titlebar setup (transparent with toolbar) + /// This configuration automatically handles fullscreen mode natively + static Future setupCustomTitlebar() async { + if (!Platform.isMacOS) return; + + // Enable window delegate to use presentation options and fullscreen callbacks + await WindowManipulator.initialize(enableWindowDelegate: true); + + // Register custom delegate to handle fullscreen transitions + final delegate = FullscreenWindowDelegate(); + WindowManipulator.addNSWindowDelegate(delegate); + + // Make titlebar transparent but keep it functional + await WindowManipulator.makeTitlebarTransparent(); + await WindowManipulator.hideTitle(); + await WindowManipulator.enableFullSizeContentView(); + + // Add toolbar to create space for traffic lights in normal mode + await WindowManipulator.addToolbar(); + + // Set custom traffic light positions for normal mode + await _setCustomButtonPositions(); + + // Configure fullscreen presentation to auto-hide toolbar and menubar + // This tells macOS to automatically hide the toolbar when entering fullscreen + final presentationOptions = NSAppPresentationOptions.from({ + NSAppPresentationOption.fullScreen, + NSAppPresentationOption.autoHideToolbar, + NSAppPresentationOption.autoHideMenuBar, + NSAppPresentationOption.autoHideDock, + }); + presentationOptions.applyAsFullScreenPresentationOptions(); + } + + /// Set traffic light buttons to custom positions (with toolbar offset) + static Future _setCustomButtonPositions() async { + await WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.closeButton, + offset: const Offset(20, _customButtonY), + ); + await WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.miniaturizeButton, + offset: const Offset(40, _customButtonY), + ); + await WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.zoomButton, + offset: const Offset(60, _customButtonY), + ); + } +} diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart new file mode 100644 index 000000000..ca3f65f87 --- /dev/null +++ b/lib/services/plex_auth_service.dart @@ -0,0 +1,370 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:uuid/uuid.dart'; +import 'storage_service.dart'; +import '../client/plex_client.dart'; +import '../models/plex_user_profile.dart'; + +class PlexAuthService { + static const String _appName = 'Plezy'; + static const String _plexApiBase = 'https://plex.tv/api/v2'; + static const String _clientsApi = 'https://clients.plex.tv/api/v2'; + + final Dio _dio; + late final String _clientIdentifier; + + PlexAuthService._(this._dio, this._clientIdentifier); + + static Future create() async { + final storage = await StorageService.getInstance(); + final dio = Dio(); + + // Get or create client identifier + String? clientId = storage.getClientIdentifier(); + if (clientId == null) { + clientId = const Uuid().v4(); + await storage.saveClientIdentifier(clientId); + } + + return PlexAuthService._(dio, clientId); + } + + String get clientIdentifier => _clientIdentifier; + + /// Verify if a plex.tv token is valid + Future verifyToken(String token) async { + try { + final response = await _dio.get( + '$_plexApiBase/user', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Token': token, + }, + validateStatus: (status) => status != null && status < 500, + ), + ); + return response.statusCode == 200; + } catch (e) { + return false; + } + } + + /// Create a PIN for authentication + Future> createPin() async { + final response = await _dio.post( + '$_plexApiBase/pins?strong=true', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + }, + ), + ); + + return response.data as Map; + } + + /// Construct the Auth App URL for the user to visit + String getAuthUrl(String pinCode) { + final params = { + 'clientID': _clientIdentifier, + 'code': pinCode, + 'context[device][product]': _appName, + }; + + final queryString = params.entries + .map( + (e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', + ) + .join('&'); + + return 'https://app.plex.tv/auth#?$queryString'; + } + + /// Poll the PIN to check if it has been claimed + Future checkPin(int pinId) async { + try { + final response = await _dio.get( + '$_plexApiBase/pins/$pinId', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Client-Identifier': _clientIdentifier, + }, + ), + ); + + final data = response.data as Map; + return data['authToken'] as String?; + } catch (e) { + return null; + } + } + + /// Poll the PIN until it's claimed or timeout + Future pollPinUntilClaimed( + int pinId, { + Duration timeout = const Duration(minutes: 5), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + final token = await checkPin(pinId); + if (token != null) { + return token; + } + + // Wait 1 second before polling again + await Future.delayed(const Duration(seconds: 1)); + } + + return null; // Timeout + } + + /// Fetch available Plex servers for the authenticated user + Future> fetchServers(String plexToken) async { + final response = await _dio.get( + '$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Token': plexToken, + }, + ), + ); + + final List resources = response.data as List; + + // Filter for server resources and map to PlexServer objects + return resources + .where((r) => r['provides'] == 'server') + .map((r) => PlexServer.fromJson(r as Map)) + .toList(); + } + + /// Get user information + Future> getUserInfo(String token) async { + final response = await _dio.get( + '$_plexApiBase/user', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Token': token, + }, + ), + ); + + return response.data as Map; + } + + /// Get user profile with preferences (audio/subtitle settings) + Future getUserProfile(String token) async { + final response = await _dio.get( + '$_clientsApi/user', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Token': token, + }, + ), + ); + + return PlexUserProfile.fromJson(response.data as Map); + } +} + +/// Represents a Plex Media Server +class PlexServer { + final String name; + final String clientIdentifier; + final String accessToken; + final List connections; + final bool owned; + final String? product; + final String? platform; + final DateTime? lastSeenAt; + final bool presence; + + PlexServer({ + required this.name, + required this.clientIdentifier, + required this.accessToken, + required this.connections, + required this.owned, + this.product, + this.platform, + this.lastSeenAt, + this.presence = false, + }); + + factory PlexServer.fromJson(Map json) { + final List connectionsJson = json['connections'] as List; + final connections = connectionsJson + .map((c) => PlexConnection.fromJson(c as Map)) + .toList(); + + DateTime? lastSeenAt; + if (json['lastSeenAt'] != null) { + try { + lastSeenAt = DateTime.parse(json['lastSeenAt'] as String); + } catch (e) { + lastSeenAt = null; + } + } + + return PlexServer( + name: json['name'] as String, + clientIdentifier: json['clientIdentifier'] as String, + accessToken: json['accessToken'] as String, + connections: connections, + owned: json['owned'] as bool? ?? false, + product: json['product'] as String?, + platform: json['platform'] as String?, + lastSeenAt: lastSeenAt, + presence: json['presence'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'name': name, + 'clientIdentifier': clientIdentifier, + 'accessToken': accessToken, + 'connections': connections.map((c) => c.toJson()).toList(), + 'owned': owned, + 'product': product, + 'platform': platform, + 'lastSeenAt': lastSeenAt?.toIso8601String(), + 'presence': presence, + }; + } + + /// Check if server is online using the presence field + bool get isOnline => presence; + + /// Get the best connection URL + /// Priority: local > remote > relay + PlexConnection? getBestConnection() { + if (connections.isEmpty) return null; + + // Try to find local connection first + final local = connections.where((c) => c.local && !c.relay).toList(); + if (local.isNotEmpty) return local.first; + + // Try remote (non-relay) connection + final remote = connections.where((c) => !c.local && !c.relay).toList(); + if (remote.isNotEmpty) return remote.first; + + // Fall back to relay as last resort + final relay = connections.where((c) => c.relay).toList(); + if (relay.isNotEmpty) return relay.first; + + // Return any connection + return connections.first; + } + + /// Find the best working connection by testing them + /// Tests ALL connections simultaneously and returns the best working one + /// Priority: local > remote > relay (from successful connections) + Future findBestWorkingConnection() async { + if (connections.isEmpty) return null; + + // Test all connections simultaneously + final results = await Future.wait( + connections.map((connection) async { + final works = await PlexClient.testConnectionUrl( + connection.uri, + accessToken, + ); + return works ? connection : null; + }), + ); + + // Filter out failed connections + final workingConnections = results + .where((c) => c != null) + .cast() + .toList(); + + if (workingConnections.isEmpty) return null; + + // From working connections, prefer local > remote > relay + final localWorking = workingConnections + .where((c) => c.local && !c.relay) + .toList(); + if (localWorking.isNotEmpty) return localWorking.first; + + final remoteWorking = workingConnections + .where((c) => !c.local && !c.relay) + .toList(); + if (remoteWorking.isNotEmpty) return remoteWorking.first; + + final relayWorking = workingConnections.where((c) => c.relay).toList(); + if (relayWorking.isNotEmpty) return relayWorking.first; + + // Fallback to any working connection + return workingConnections.first; + } +} + +/// Represents a connection to a Plex server +class PlexConnection { + final String protocol; + final String address; + final int port; + final String uri; + final bool local; + final bool relay; + final bool ipv6; + + PlexConnection({ + required this.protocol, + required this.address, + required this.port, + required this.uri, + required this.local, + required this.relay, + required this.ipv6, + }); + + factory PlexConnection.fromJson(Map json) { + return PlexConnection( + protocol: json['protocol'] as String, + address: json['address'] as String, + port: json['port'] as int, + uri: json['uri'] as String, + local: json['local'] as bool? ?? false, + relay: json['relay'] as bool? ?? false, + ipv6: json['IPv6'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'protocol': protocol, + 'address': address, + 'port': port, + 'uri': uri, + 'local': local, + 'relay': relay, + 'IPv6': ipv6, + }; + } + + String get displayType { + if (relay) return 'Relay'; + if (local) return 'Local'; + return 'Remote'; + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart new file mode 100644 index 000000000..a863ac639 --- /dev/null +++ b/lib/services/storage_service.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +class StorageService { + static const String _keyServerUrl = 'server_url'; + static const String _keyToken = 'token'; + static const String _keyPlexToken = 'plex_token'; + static const String _keyServerData = 'server_data'; + static const String _keyClientId = 'client_identifier'; + static const String _keySelectedLibraryIndex = 'selected_library_index'; + static const String _keyLibraryFilters = 'library_filters'; + static const String _keyUserProfile = 'user_profile'; + + static StorageService? _instance; + late SharedPreferences _prefs; + + StorageService._(); + + static Future getInstance() async { + if (_instance == null) { + _instance = StorageService._(); + await _instance!._init(); + } + return _instance!; + } + + Future _init() async { + _prefs = await SharedPreferences.getInstance(); + } + + // Server URL + Future saveServerUrl(String url) async { + await _prefs.setString(_keyServerUrl, url); + } + + String? getServerUrl() { + return _prefs.getString(_keyServerUrl); + } + + // Server Access Token + Future saveToken(String token) async { + await _prefs.setString(_keyToken, token); + } + + String? getToken() { + return _prefs.getString(_keyToken); + } + + // Alias for server access token for clarity + Future saveServerAccessToken(String token) async { + await saveToken(token); + } + + String? getServerAccessToken() { + return getToken(); + } + + // Plex.tv Token (for API access) + Future savePlexToken(String token) async { + await _prefs.setString(_keyPlexToken, token); + } + + String? getPlexToken() { + return _prefs.getString(_keyPlexToken); + } + + // Server Data (full PlexServer object as JSON) + Future saveServerData(Map serverJson) async { + final jsonString = json.encode(serverJson); + await _prefs.setString(_keyServerData, jsonString); + } + + Map? getServerData() { + final jsonString = _prefs.getString(_keyServerData); + if (jsonString == null) return null; + + try { + return json.decode(jsonString) as Map; + } catch (e) { + return null; + } + } + + // Client Identifier + Future saveClientIdentifier(String clientId) async { + await _prefs.setString(_keyClientId, clientId); + } + + String? getClientIdentifier() { + return _prefs.getString(_keyClientId); + } + + // Save all credentials at once + Future saveCredentials({ + required String serverUrl, + required String token, + required String clientIdentifier, + }) async { + await Future.wait([ + saveServerUrl(serverUrl), + saveToken(token), + saveClientIdentifier(clientIdentifier), + ]); + } + + // Check if credentials exist + bool hasCredentials() { + return getServerUrl() != null && getToken() != null; + } + + // Clear all credentials + Future clearCredentials() async { + await Future.wait([ + _prefs.remove(_keyServerUrl), + _prefs.remove(_keyToken), + _prefs.remove(_keyPlexToken), + _prefs.remove(_keyServerData), + _prefs.remove(_keyClientId), + _prefs.remove(_keyUserProfile), + ]); + } + + // Get all credentials as a map + Map getCredentials() { + return { + 'serverUrl': getServerUrl(), + 'token': getToken(), + 'clientIdentifier': getClientIdentifier(), + }; + } + + // Selected Library Index + Future saveSelectedLibraryIndex(int index) async { + await _prefs.setInt(_keySelectedLibraryIndex, index); + } + + int? getSelectedLibraryIndex() { + return _prefs.getInt(_keySelectedLibraryIndex); + } + + // Library Filters (stored as JSON string) + Future saveLibraryFilters(Map filters) async { + final jsonString = json.encode(filters); + await _prefs.setString(_keyLibraryFilters, jsonString); + } + + Map getLibraryFilters() { + final jsonString = _prefs.getString(_keyLibraryFilters); + if (jsonString == null) return {}; + + try { + final decoded = json.decode(jsonString) as Map; + return decoded.map((key, value) => MapEntry(key, value.toString())); + } catch (e) { + return {}; + } + } + + // Clear library preferences + Future clearLibraryPreferences() async { + await Future.wait([ + _prefs.remove(_keySelectedLibraryIndex), + _prefs.remove(_keyLibraryFilters), + ]); + } + + // User Profile (stored as JSON string) + Future saveUserProfile(Map profileJson) async { + final jsonString = json.encode(profileJson); + await _prefs.setString(_keyUserProfile, jsonString); + } + + Map? getUserProfile() { + final jsonString = _prefs.getString(_keyUserProfile); + if (jsonString == null) return null; + + try { + return json.decode(jsonString) as Map; + } catch (e) { + return null; + } + } +} diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart new file mode 100644 index 000000000..70a4b2b21 --- /dev/null +++ b/lib/utils/app_logger.dart @@ -0,0 +1,24 @@ +import 'package:logger/logger.dart'; + +/// Centralized logger instance for the application. +/// +/// Usage: +/// ```dart +/// import 'package:plezy/utils/app_logger.dart'; +/// +/// appLogger.d('Debug message'); +/// appLogger.i('Info message'); +/// appLogger.w('Warning message'); +/// appLogger.e('Error message', error: e, stackTrace: stackTrace); +/// ``` +final appLogger = Logger( + printer: PrettyPrinter( + methodCount: 2, + errorMethodCount: 8, + lineLength: 120, + colors: true, + printEmojis: true, + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, + ), + level: Level.debug, +); diff --git a/lib/utils/language_codes.dart b/lib/utils/language_codes.dart new file mode 100644 index 000000000..312536965 --- /dev/null +++ b/lib/utils/language_codes.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; + +/// Helper class for converting between ISO 639-1 (2-letter) and ISO 639-2 (3-letter) language codes +class LanguageCodes { + static Map? _codes; + + /// Load the language codes from JSON + static Future initialize() async { + if (_codes != null) return; + + final jsonString = await rootBundle.loadString( + 'lib/data/iso_639_codes.json', + ); + _codes = json.decode(jsonString) as Map; + } + + /// Get all possible variations of a language code + /// Handles both ISO 639-1 (2-letter) and ISO 639-2 (3-letter) codes + /// Returns a list of codes to check against track languages + static List getVariations(String languageCode) { + if (_codes == null) { + throw StateError( + 'LanguageCodes not initialized. Call initialize() first.', + ); + } + + final normalized = languageCode.toLowerCase().trim(); + final variations = {normalized}; // Use Set to avoid duplicates + + // Check if it's a 2-letter code (ISO 639-1) + if (_codes!.containsKey(normalized)) { + final entry = _codes![normalized] as Map; + + // Add the 639-1 code + if (entry.containsKey('639-1')) { + variations.add((entry['639-1'] as String).toLowerCase()); + } + + // Add the 639-2 code + if (entry.containsKey('639-2')) { + variations.add((entry['639-2'] as String).toLowerCase()); + } + + // Add the 639-2/B code if it exists (bibliographic variant) + if (entry.containsKey('639-2/B')) { + variations.add((entry['639-2/B'] as String).toLowerCase()); + } + } else { + // It might be a 3-letter code, search for it + for (var entry in _codes!.values) { + final entryMap = entry as Map; + + // Check if this entry contains our code as 639-2 or 639-2/B + final code6392 = entryMap['639-2'] as String?; + final code6392B = entryMap['639-2/B'] as String?; + + if (code6392?.toLowerCase() == normalized || + code6392B?.toLowerCase() == normalized) { + // Add all variations from this entry + if (entryMap.containsKey('639-1')) { + variations.add((entryMap['639-1'] as String).toLowerCase()); + } + if (code6392 != null) { + variations.add(code6392.toLowerCase()); + } + if (code6392B != null) { + variations.add(code6392B.toLowerCase()); + } + break; + } + } + } + + return variations.toList(); + } + + /// Get the English name of a language from its code + static String? getLanguageName(String languageCode) { + if (_codes == null) return null; + + final normalized = languageCode.toLowerCase().trim(); + + // Check if it's a 2-letter code + if (_codes!.containsKey(normalized)) { + final entry = _codes![normalized] as Map; + return entry['name'] as String?; + } + + // Search for 3-letter code + for (var entry in _codes!.values) { + final entryMap = entry as Map; + final code6392 = entryMap['639-2'] as String?; + final code6392B = entryMap['639-2/B'] as String?; + + if (code6392?.toLowerCase() == normalized || + code6392B?.toLowerCase() == normalized) { + return entryMap['name'] as String?; + } + } + + return null; + } +} diff --git a/lib/utils/plex_headers.dart b/lib/utils/plex_headers.dart new file mode 100644 index 000000000..f5ead39ce --- /dev/null +++ b/lib/utils/plex_headers.dart @@ -0,0 +1,37 @@ +/// Utility class for building Plex API headers +class PlexHeaders { + /// Standard Plex headers required for API requests + static const String plexClientIdentifier = 'X-Plex-Client-Identifier'; + static const String plexProduct = 'X-Plex-Product'; + static const String plexVersion = 'X-Plex-Version'; + static const String plexToken = 'X-Plex-Token'; + static const String plexPlatform = 'X-Plex-Platform'; + static const String plexPlatformVersion = 'X-Plex-Platform-Version'; + static const String plexDevice = 'X-Plex-Device'; + + /// Builds standard Plex headers with optional token + static Map buildHeaders({ + required String clientIdentifier, + String? token, + String product = 'Plezy', + String version = '1.0', + String platform = 'Flutter', + String platformVersion = '1.0', + String device = 'Mobile', + }) { + final headers = { + plexClientIdentifier: clientIdentifier, + plexProduct: product, + plexVersion: version, + plexPlatform: platform, + plexPlatformVersion: platformVersion, + plexDevice: device, + }; + + if (token != null) { + headers[plexToken] = token; + } + + return headers; + } +} diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart new file mode 100644 index 000000000..0d82667f9 --- /dev/null +++ b/lib/widgets/desktop_app_bar.dart @@ -0,0 +1,265 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.dart'; +import '../services/fullscreen_state_manager.dart'; + +class DesktopWindowPadding { + /// Left padding for macOS traffic lights (normal window mode) + static const double macOSLeft = 80.0; + + /// Left padding for macOS in fullscreen (reduced since traffic lights auto-hide) + static const double macOSLeftFullscreen = 0.0; + + /// Right padding for macOS to prevent actions from being too close to edge + static const double macOSRight = 16.0; +} + +/// A widget that adds padding to account for desktop window controls. +/// On macOS, adds left padding for traffic lights (reduced in fullscreen). +class DesktopTitleBarPadding extends StatelessWidget { + final Widget child; + final double? leftPadding; + final double? rightPadding; + + const DesktopTitleBarPadding({ + super.key, + required this.child, + this.leftPadding, + this.rightPadding, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + double left = 0.0; + double right = 0.0; + + if (Platform.isMacOS) { + final isFullscreen = FullscreenStateManager().isFullscreen; + // In fullscreen, use minimal padding since traffic lights auto-hide + left = + leftPadding ?? + (isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft); + } + + if (left == 0.0 && right == 0.0) { + return child; + } + + return Padding( + padding: EdgeInsets.only(left: left, right: right), + child: child, + ); + }, + ); + } +} + +/// A custom app bar that automatically handles desktop window controls spacing. +/// Use this instead of AppBar for consistent desktop platform behavior. +class DesktopAppBar extends StatelessWidget implements PreferredSizeWidget { + final Widget? title; + final List? actions; + final Widget? leading; + final bool automaticallyImplyLeading; + final double? elevation; + final Color? backgroundColor; + final Color? surfaceTintColor; + final Color? shadowColor; + final double? scrolledUnderElevation; + + const DesktopAppBar({ + super.key, + this.title, + this.actions, + this.leading, + this.automaticallyImplyLeading = true, + this.elevation, + this.backgroundColor, + this.surfaceTintColor, + this.shadowColor, + this.scrolledUnderElevation, + }); + + @override + Widget build(BuildContext context) { + // Add right padding for desktop platforms + List? adjustedActions = actions; + + if (Platform.isMacOS) { + // macOS: Add padding to keep actions away from edge + if (actions != null) { + adjustedActions = [ + ...actions!, + SizedBox(width: DesktopWindowPadding.macOSRight), + ]; + } else { + adjustedActions = [SizedBox(width: DesktopWindowPadding.macOSRight)]; + } + } + + // Wrap leading widget with padding on macOS to avoid traffic lights + Widget? adjustedLeading = leading; + if (Platform.isMacOS && leading != null) { + adjustedLeading = ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + final leftPadding = isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft; + return Padding( + padding: EdgeInsets.only(left: leftPadding), + child: leading, + ); + }, + ); + } + + final appBar = AppBar( + title: title != null ? DesktopTitleBarPadding(child: title!) : null, + actions: adjustedActions, + leading: adjustedLeading, + automaticallyImplyLeading: automaticallyImplyLeading, + elevation: elevation, + backgroundColor: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + scrolledUnderElevation: scrolledUnderElevation, + ); + + // On macOS with transparent titlebar, wrap in GestureDetector to prevent + // window dragging and allow buttons to be clickable + if (Platform.isMacOS) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: appBar, + ); + } + + return appBar; + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + +/// A custom sliver app bar that automatically handles desktop window controls spacing. +/// Use this instead of SliverAppBar for consistent desktop platform behavior. +class DesktopSliverAppBar extends StatelessWidget { + final Widget? title; + final List? actions; + final Widget? leading; + final bool automaticallyImplyLeading; + final double? elevation; + final Color? backgroundColor; + final Color? surfaceTintColor; + final Color? shadowColor; + final double? scrolledUnderElevation; + final bool floating; + final bool pinned; + final double? expandedHeight; + final Widget? flexibleSpace; + final PreferredSizeWidget? bottom; + + const DesktopSliverAppBar({ + super.key, + this.title, + this.actions, + this.leading, + this.automaticallyImplyLeading = true, + this.elevation, + this.backgroundColor, + this.surfaceTintColor, + this.shadowColor, + this.scrolledUnderElevation, + this.floating = false, + this.pinned = false, + this.expandedHeight, + this.flexibleSpace, + this.bottom, + }); + + @override + Widget build(BuildContext context) { + // Add right padding for desktop platforms + List? adjustedActions = actions; + + if (Platform.isMacOS) { + // macOS: Add padding to keep actions away from edge + if (actions != null) { + adjustedActions = [ + ...actions!, + SizedBox(width: DesktopWindowPadding.macOSRight), + ]; + } else { + adjustedActions = [SizedBox(width: DesktopWindowPadding.macOSRight)]; + } + } + + // Wrap leading widget with gesture detector and padding on macOS + Widget? adjustedLeading = leading; + if (Platform.isMacOS && leading != null) { + adjustedLeading = ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + final leftPadding = isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: + (_) {}, // Consume pan gestures to prevent window dragging + child: Padding( + padding: EdgeInsets.only(left: leftPadding), + child: leading, + ), + ); + }, + ); + } + + // Wrap flexible space with gesture detector on macOS to prevent window dragging + Widget? adjustedFlexibleSpace = flexibleSpace; + if (Platform.isMacOS && flexibleSpace != null) { + adjustedFlexibleSpace = GestureDetector( + behavior: HitTestBehavior.translucent, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: flexibleSpace, + ); + } + + // On macOS, increase leading width to account for traffic light spacing + double? leadingWidth; + if (Platform.isMacOS && leading != null) { + final isFullscreen = FullscreenStateManager().isFullscreen; + final leftPadding = isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft; + leadingWidth = leftPadding + kToolbarHeight; + } + + return SliverAppBar( + title: title != null ? DesktopTitleBarPadding(child: title!) : null, + actions: adjustedActions, + leading: adjustedLeading, + leadingWidth: leadingWidth, + automaticallyImplyLeading: automaticallyImplyLeading, + elevation: elevation, + backgroundColor: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + scrolledUnderElevation: scrolledUnderElevation, + floating: floating, + pinned: pinned, + expandedHeight: expandedHeight, + flexibleSpace: adjustedFlexibleSpace, + bottom: bottom, + ); + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart new file mode 100644 index 000000000..82672113d --- /dev/null +++ b/lib/widgets/media_card.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../screens/media_detail_screen.dart'; +import '../screens/season_detail_screen.dart'; +import '../screens/video_player_screen.dart'; +import 'media_context_menu.dart'; + +class MediaCard extends StatefulWidget { + final PlexClient client; + final PlexMetadata item; + final double? width; + final double? height; + final VoidCallback? onRefresh; + final PlexUserProfile? userProfile; + + const MediaCard({ + super.key, + required this.client, + required this.item, + this.width, + this.height, + this.onRefresh, + this.userProfile, + }); + + @override + State createState() => _MediaCardState(); +} + +class _MediaCardState extends State { + void _handleTap(BuildContext context) async { + final itemType = widget.item.type.toLowerCase(); + + // For episodes, start playback directly + if (itemType == 'episode') { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: widget.item, + userProfile: widget.userProfile, + ), + ), + ); + // Refresh parent screen if result indicates it's needed + if (result == true) { + widget.onRefresh?.call(); + } + } else if (itemType == 'season') { + // For seasons, show season detail screen + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeasonDetailScreen( + client: widget.client, + season: widget.item, + userProfile: widget.userProfile, + ), + ), + ); + // Season screen doesn't return a refresh flag, but we can refresh anyway + widget.onRefresh?.call(); + } else { + // For all other types (shows, movies), show detail screen + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen( + client: widget.client, + metadata: widget.item, + userProfile: widget.userProfile, + ), + ), + ); + // Refresh parent screen if result indicates it's needed + if (result == true) { + widget.onRefresh?.call(); + } + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + child: MediaContextMenu( + client: widget.client, + metadata: widget.item, + onRefresh: widget.onRefresh, + onTap: () => _handleTap(context), + child: InkWell( + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Poster + if (widget.height != null) + SizedBox( + width: double.infinity, + height: widget.height, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: double.infinity, + height: widget.height, + child: _buildPosterImage(context), + ), + ), + if (widget.item.isWatched) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 16, + ), + ), + ), + // Progress bar for partially watched episodes + if (widget.item.viewOffset != null && + widget.item.duration != null && + widget.item.viewOffset! > 0 && + !widget.item.isWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + child: LinearProgressIndicator( + value: + widget.item.viewOffset! / + widget.item.duration!, + backgroundColor: Colors.black.withValues( + alpha: 0.5, + ), + valueColor: const AlwaysStoppedAnimation( + Colors.red, + ), + minHeight: 4, + ), + ), + ), + ], + ), + ) + else + Expanded( + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildPosterImage(context), + ), + if (widget.item.isWatched) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 16, + ), + ), + ), + // Progress bar for partially watched episodes + if (widget.item.viewOffset != null && + widget.item.duration != null && + widget.item.viewOffset! > 0 && + !widget.item.isWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + child: LinearProgressIndicator( + value: + widget.item.viewOffset! / + widget.item.duration!, + backgroundColor: Colors.black.withValues( + alpha: 0.5, + ), + valueColor: const AlwaysStoppedAnimation( + Colors.red, + ), + minHeight: 4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + // Text content + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + widget.item.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + height: 1.1, + ), + ), + if (widget.item.displaySubtitle != null) + Text( + widget.item.displaySubtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + height: 1.1, + ), + ) + else if (widget.item.parentTitle != null) + Text( + widget.item.parentTitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + height: 1.1, + ), + ) + else if (widget.item.year != null) + Text( + '${widget.item.year}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + height: 1.1, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildPosterImage(BuildContext context) { + if (widget.item.posterThumb != null) { + return CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl(widget.item.posterThumb), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (context, url) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.broken_image, size: 40)), + ), + ); + } else { + return Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.movie, size: 40)), + ); + } + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart new file mode 100644 index 000000000..fd6eb5313 --- /dev/null +++ b/lib/widgets/media_context_menu.dart @@ -0,0 +1,285 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../screens/media_detail_screen.dart'; +import '../screens/season_detail_screen.dart'; + +/// Helper class to store menu action data +class _MenuAction { + final String value; + final IconData icon; + final String label; + + _MenuAction({ + required this.value, + required this.icon, + required this.label, + }); +} + +/// A reusable wrapper widget that adds a context menu (long press / right click) +/// to any media item with appropriate actions based on the item type. +class MediaContextMenu extends StatefulWidget { + final PlexClient client; + final PlexMetadata metadata; + final VoidCallback? onRefresh; + final VoidCallback? onTap; + final Widget child; + + const MediaContextMenu({ + super.key, + required this.client, + required this.metadata, + this.onRefresh, + this.onTap, + required this.child, + }); + + @override + State createState() => _MediaContextMenuState(); +} + +class _MediaContextMenuState extends State { + Offset? _tapPosition; + + void _storeTapPosition(TapDownDetails details) { + _tapPosition = details.globalPosition; + } + + void _showContextMenu(BuildContext context) async { + final itemType = widget.metadata.type.toLowerCase(); + final isPartiallyWatched = + widget.metadata.viewedLeafCount != null && + widget.metadata.leafCount != null && + widget.metadata.viewedLeafCount! > 0 && + widget.metadata.viewedLeafCount! < widget.metadata.leafCount!; + + // Check if we should use bottom sheet (on iOS and Android) + final useBottomSheet = Platform.isIOS || Platform.isAndroid; + + // Build menu actions + final menuActions = <_MenuAction>[]; + + // Mark as Watched + if (!widget.metadata.isWatched || isPartiallyWatched) { + menuActions.add( + _MenuAction( + value: 'watch', + icon: Icons.check_circle_outline, + label: 'Mark as Watched', + ), + ); + } + + // Mark as Unwatched + if (widget.metadata.isWatched || isPartiallyWatched) { + menuActions.add( + _MenuAction( + value: 'unwatch', + icon: Icons.remove_circle_outline, + label: 'Mark as Unwatched', + ), + ); + } + + // Go to Series (for episodes and seasons) + if ((itemType == 'episode' || itemType == 'season') && + widget.metadata.grandparentTitle != null) { + menuActions.add( + _MenuAction( + value: 'series', + icon: Icons.tv, + label: 'Go to series', + ), + ); + } + + // Go to Season (for episodes) + if (itemType == 'episode' && widget.metadata.parentTitle != null) { + menuActions.add( + _MenuAction( + value: 'season', + icon: Icons.playlist_play, + label: 'Go to season', + ), + ); + } + + String? selected; + + if (useBottomSheet) { + // Show bottom sheet on mobile + selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + widget.metadata.title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ...menuActions.map((action) => ListTile( + leading: Icon(action.icon), + title: Text(action.label), + onTap: () => Navigator.pop(context, action.value), + )), + ], + ), + ), + ); + } else { + // Show popup menu on larger screens + final menuItems = menuActions.map((action) => PopupMenuItem( + value: action.value, + child: Row( + children: [ + Icon(action.icon), + const SizedBox(width: 12), + Expanded(child: Text(action.label)), + ], + ), + )).toList(); + + // Use stored tap position or fallback to widget position + final RenderBox? overlay = + Overlay.of(context).context.findRenderObject() as RenderBox?; + + Offset position; + if (_tapPosition != null) { + position = _tapPosition!; + } else { + final RenderBox renderBox = context.findRenderObject() as RenderBox; + position = renderBox.localToGlobal(Offset.zero, ancestor: overlay); + } + + selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy, + position.dx, + position.dy, + ), + items: menuItems, + ); + } + + if (!context.mounted) return; + + switch (selected) { + case 'watch': + try { + await widget.client.markAsWatched(widget.metadata.ratingKey); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Marked as watched'))); + // Refresh parent screen to update UI + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + break; + case 'unwatch': + try { + await widget.client.markAsUnwatched(widget.metadata.ratingKey); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Marked as unwatched')), + ); + // Refresh parent screen to update UI + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + break; + case 'series': + // Navigate to series detail screen + if (widget.metadata.grandparentRatingKey != null) { + try { + final seriesMetadata = await widget.client.getMetadata( + widget.metadata.grandparentRatingKey!, + ); + if (seriesMetadata != null && context.mounted) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen( + client: widget.client, + metadata: seriesMetadata, + ), + ), + ); + // Refresh parent screen after returning + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading series: $e')), + ); + } + } + } + break; + case 'season': + // Navigate to season detail screen + if (widget.metadata.parentRatingKey != null) { + try { + final seasonMetadata = await widget.client.getMetadata( + widget.metadata.parentRatingKey!, + ); + if (seasonMetadata != null && context.mounted) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeasonDetailScreen( + client: widget.client, + season: seasonMetadata, + ), + ), + ); + // Refresh parent screen after returning + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading season: $e')), + ); + } + } + } + break; + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: widget.onTap, + onTapDown: _storeTapPosition, + onLongPress: () => _showContextMenu(context), + onSecondaryTapDown: _storeTapPosition, + onSecondaryTap: () => _showContextMenu(context), + child: widget.child, + ); + } +} diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart new file mode 100644 index 000000000..de07f8af0 --- /dev/null +++ b/lib/widgets/plex_video_controls.dart @@ -0,0 +1,1399 @@ +import 'dart:async'; +import 'dart:io' show Platform; +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:macos_window_utils/macos_window_utils.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_media_info.dart'; +import '../services/fullscreen_state_manager.dart'; +import 'desktop_app_bar.dart'; + +/// Custom video controls builder for Plex with chapter, audio, and subtitle support +Widget plexVideoControlsBuilder( + Player player, + PlexClient client, + PlexMetadata metadata, { + VoidCallback? onNext, + VoidCallback? onPrevious, +}) { + return PlexVideoControls( + player: player, + client: client, + metadata: metadata, + onNext: onNext, + onPrevious: onPrevious, + ); +} + +class PlexVideoControls extends StatefulWidget { + final Player player; + final PlexClient client; + final PlexMetadata metadata; + final VoidCallback? onNext; + final VoidCallback? onPrevious; + + const PlexVideoControls({ + super.key, + required this.player, + required this.client, + required this.metadata, + this.onNext, + this.onPrevious, + }); + + @override + State createState() => _PlexVideoControlsState(); +} + +class _PlexVideoControlsState extends State + with WindowListener { + bool _showControls = true; + List _chapters = []; + bool _chaptersLoaded = false; + Timer? _hideTimer; + bool _isFullscreen = false; + + @override + void initState() { + super.initState(); + _loadChapters(); + _startHideTimer(); + // Add window listener for tracking fullscreen state (for button icon) + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + windowManager.addListener(this); + } + } + + @override + void dispose() { + _hideTimer?.cancel(); + // Remove window listener + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + windowManager.removeListener(this); + } + super.dispose(); + } + + @override + void onWindowEnterFullScreen() { + if (mounted) { + setState(() { + _isFullscreen = true; + }); + } + } + + @override + void onWindowLeaveFullScreen() { + if (mounted) { + setState(() { + _isFullscreen = false; + }); + } + } + + @override + void onWindowMaximize() { + // On macOS, maximize is the same as fullscreen (green button) + if (mounted && Platform.isMacOS) { + setState(() { + _isFullscreen = true; + }); + } + } + + @override + void onWindowUnmaximize() { + // On macOS, unmaximize means exiting fullscreen + if (mounted && Platform.isMacOS) { + setState(() { + _isFullscreen = false; + }); + } + } + + void _startHideTimer() { + _hideTimer?.cancel(); + // Only auto-hide if playing + if (widget.player.state.playing) { + _hideTimer = Timer(const Duration(seconds: 3), () { + if (mounted && widget.player.state.playing) { + setState(() { + _showControls = false; + }); + // Hide traffic lights on macOS when controls auto-hide + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + }); + } + } + + void _toggleControls() { + setState(() { + _showControls = !_showControls; + }); + if (_showControls) { + _startHideTimer(); + } + + // On macOS, hide/show traffic lights with controls + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + + void _updateTrafficLightVisibility() async { + if (Platform.isMacOS) { + if (_showControls) { + await WindowManipulator.showCloseButton(); + await WindowManipulator.showMiniaturizeButton(); + await WindowManipulator.showZoomButton(); + } else { + await WindowManipulator.hideCloseButton(); + await WindowManipulator.hideMiniaturizeButton(); + await WindowManipulator.hideZoomButton(); + } + } + } + + Future _loadChapters() async { + final chapters = await widget.client.getChapters(widget.metadata.ratingKey); + if (mounted) { + setState(() { + _chapters = chapters; + _chaptersLoaded = true; + }); + } + } + + bool _isMobile(BuildContext context) { + final platform = Theme.of(context).platform; + return platform == TargetPlatform.iOS || platform == TargetPlatform.android; + } + + void _seekToPreviousChapter() { + if (_chapters.isEmpty) { + // No chapters - seek backward 10 seconds + final currentPosition = widget.player.state.position; + widget.player.seek(currentPosition - const Duration(seconds: 10)); + return; + } + + final currentPosition = widget.player.state.position.inMilliseconds; + + // Find current chapter + for (int i = _chapters.length - 1; i >= 0; i--) { + final chapterStart = _chapters[i].startTimeOffset ?? 0; + if (currentPosition > chapterStart + 3000) { + // If more than 3 seconds into chapter, go to start of current chapter + widget.player.seek(Duration(milliseconds: chapterStart)); + return; + } + } + + // If at start of first chapter, go to beginning + widget.player.seek(Duration.zero); + } + + void _seekToNextChapter() { + if (_chapters.isEmpty) { + // No chapters - seek forward 10 seconds + final currentPosition = widget.player.state.position; + widget.player.seek(currentPosition + const Duration(seconds: 10)); + return; + } + + final currentPosition = widget.player.state.position.inMilliseconds; + + // Find next chapter + for (int i = 0; i < _chapters.length; i++) { + final chapterStart = _chapters[i].startTimeOffset ?? 0; + if (chapterStart > currentPosition) { + widget.player.seek(Duration(milliseconds: chapterStart)); + return; + } + } + } + + Future _toggleFullscreen() async { + if (!_isMobile(context)) { + setState(() { + _isFullscreen = !_isFullscreen; + }); + + if (Platform.isMacOS) { + // Use native macOS fullscreen - titlebar is handled automatically + if (_isFullscreen) { + await WindowManipulator.enterFullscreen(); + } else { + await WindowManipulator.exitFullscreen(); + } + } else { + // For Windows/Linux, use window_manager + if (_isFullscreen) { + await windowManager.setFullScreen(true); + } else { + await windowManager.setFullScreen(false); + } + } + } + } + + @override + Widget build(BuildContext context) { + final isMobile = _isMobile(context); + + return Stack( + children: [ + // Invisible tap detector that always covers the full area + Positioned.fill( + child: GestureDetector( + onTap: _toggleControls, + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + // Custom controls overlay - use AnimatedOpacity to keep widget tree alive + Positioned.fill( + child: IgnorePointer( + ignoring: !_showControls, + child: AnimatedOpacity( + opacity: _showControls ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: GestureDetector( + onTap: _toggleControls, + behavior: HitTestBehavior.deferToChild, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + ], + stops: const [0.0, 0.2, 0.8, 1.0], + ), + ), + child: isMobile + ? _buildMobileLayout() + : _buildDesktopLayout(), + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildMobileLayout() { + return Column( + children: [ + // Top bar with back button and track/chapter controls + _buildMobileTopBar(), + const Spacer(), + // Centered large playback controls + _buildMobilePlaybackControls(), + const Spacer(), + // Progress bar at bottom + _buildMobileBottomBar(), + ], + ); + } + + Widget _buildDesktopLayout() { + return Column( + children: [ + // Top bar with back button and title + _buildDesktopTopBar(), + const Spacer(), + // Bottom controls + _buildDesktopBottomControls(), + ], + ); + } + + // Mobile layout components + Widget _buildMobileTopBar() { + final topBar = Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(true), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.metadata.grandparentTitle ?? widget.metadata.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.metadata.parentIndex != null && + widget.metadata.index != null) + Text( + 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', + style: const TextStyle(color: Colors.white70, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Track and chapter controls in top right + IconButton( + icon: const Icon(Icons.audiotrack, color: Colors.white), + onPressed: _showAudioBottomSheet, + ), + IconButton( + icon: const Icon(Icons.subtitles, color: Colors.white), + onPressed: _showSubtitleBottomSheet, + ), + IconButton( + icon: const Icon(Icons.video_library, color: Colors.white), + onPressed: _showChapterBottomSheet, + ), + ], + ), + ); + + // On macOS, wrap with GestureDetector to prevent window dragging + if (Platform.isMacOS) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: topBar, + ); + } + + return topBar; + } + + Widget _buildMobilePlaybackControls() { + return StreamBuilder( + stream: widget.player.stream.playing, + builder: (context, snapshot) { + final isPlaying = snapshot.data ?? false; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon( + Icons.replay_10, + color: Colors.white, + size: 48, + ), + iconSize: 48, + onPressed: () { + final currentPosition = widget.player.state.position; + widget.player.seek( + currentPosition - const Duration(seconds: 10), + ); + }, + ), + ), + const SizedBox(width: 48), + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 72, + ), + iconSize: 72, + onPressed: () { + if (isPlaying) { + widget.player.pause(); + _hideTimer?.cancel(); // Cancel auto-hide when paused + } else { + widget.player.play(); + _startHideTimer(); // Start auto-hide when playing + } + }, + ), + ), + const SizedBox(width: 48), + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon( + Icons.forward_10, + color: Colors.white, + size: 48, + ), + iconSize: 48, + onPressed: () { + final currentPosition = widget.player.state.position; + widget.player.seek( + currentPosition + const Duration(seconds: 10), + ); + }, + ), + ), + ], + ); + }, + ); + } + + Widget _buildMobileBottomBar() { + return Padding( + padding: const EdgeInsets.all(16), + child: StreamBuilder( + stream: widget.player.stream.position, + builder: (context, positionSnapshot) { + return StreamBuilder( + stream: widget.player.stream.duration, + builder: (context, durationSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final duration = durationSnapshot.data ?? Duration.zero; + + return Column( + children: [ + _buildTimelineWithChapters( + position: position, + duration: duration, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(position), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + Text( + _formatDuration(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ); + }, + ); + }, + ), + ); + } + + // Desktop layout components + Widget _buildDesktopTopBar() { + // Use global fullscreen state for padding + return ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + // In fullscreen on macOS, use less left padding since traffic lights auto-hide + // In normal mode on macOS, need more padding to avoid traffic lights + final leftPadding = Platform.isMacOS + ? (isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft) + : DesktopWindowPadding.macOSLeftFullscreen; + + return _buildDesktopTopBarContent(leftPadding); + }, + ); + } + + Widget _buildDesktopTopBarContent(double leftPadding) { + // On macOS, use single-line layout to align with traffic lights + // On other platforms, use two-line layout with series and episode info + final isMacOS = Platform.isMacOS; + + final topBar = Padding( + padding: EdgeInsets.only( + left: leftPadding, + right: 16, + top: isMacOS ? 8 : 16, + bottom: isMacOS ? 8 : 16, + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(true), + ), + const SizedBox(width: 16), + Expanded( + child: isMacOS + ? _buildMacOSSingleLineTitle() + : _buildMultiLineTitle(), + ), + ], + ), + ); + + // On macOS, wrap with GestureDetector to prevent window dragging + if (Platform.isMacOS) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: topBar, + ); + } + + return topBar; + } + + Widget _buildMacOSSingleLineTitle() { + // Build single-line title combining series and episode info + final seriesName = + widget.metadata.grandparentTitle ?? widget.metadata.title; + final hasEpisodeInfo = + widget.metadata.parentIndex != null && widget.metadata.index != null; + + final titleText = hasEpisodeInfo + ? '$seriesName · S${widget.metadata.parentIndex} E${widget.metadata.index} · ${widget.metadata.title}' + : seriesName; + + return Text( + titleText, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + Widget _buildMultiLineTitle() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.metadata.grandparentTitle ?? widget.metadata.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.metadata.parentIndex != null && + widget.metadata.index != null) + Text( + 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', + style: const TextStyle(color: Colors.white70, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + + Widget _buildDesktopBottomControls() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + children: [ + // Row 1: Timeline with time indicators + StreamBuilder( + stream: widget.player.stream.position, + builder: (context, positionSnapshot) { + return StreamBuilder( + stream: widget.player.stream.duration, + builder: (context, durationSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final duration = durationSnapshot.data ?? Duration.zero; + + return Row( + children: [ + Text( + _formatDuration(position), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildTimelineWithChapters( + position: position, + duration: duration, + ), + ), + const SizedBox(width: 12), + Text( + _formatDuration(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ); + }, + ); + }, + ), + const SizedBox(height: 4), + // Row 2: Playback controls and options + Row( + children: [ + // Previous item + IconButton( + icon: Icon( + Icons.skip_previous, + color: widget.onPrevious != null + ? Colors.white + : Colors.white54, + ), + onPressed: widget.onPrevious, + ), + // Previous chapter (or -10s if no chapters) + IconButton( + icon: Icon( + _chapters.isEmpty ? Icons.replay_10 : Icons.fast_rewind, + color: Colors.white, + ), + onPressed: _seekToPreviousChapter, + ), + // Play/Pause + StreamBuilder( + stream: widget.player.stream.playing, + builder: (context, snapshot) { + final isPlaying = snapshot.data ?? false; + return IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 32, + ), + iconSize: 32, + onPressed: () { + if (isPlaying) { + widget.player.pause(); + _hideTimer?.cancel(); // Cancel auto-hide when paused + } else { + widget.player.play(); + _startHideTimer(); // Start auto-hide when playing + } + }, + ); + }, + ), + // Next chapter (or +10s if no chapters) + IconButton( + icon: Icon( + _chapters.isEmpty ? Icons.forward_10 : Icons.fast_forward, + color: Colors.white, + ), + onPressed: _seekToNextChapter, + ), + // Next item + IconButton( + icon: Icon( + Icons.skip_next, + color: widget.onNext != null ? Colors.white : Colors.white54, + ), + onPressed: widget.onNext, + ), + const Spacer(), + // Volume control + _buildVolumeControl(), + const SizedBox(width: 16), + // Audio track + IconButton( + icon: const Icon(Icons.audiotrack, color: Colors.white), + onPressed: _showAudioBottomSheet, + ), + // Subtitle track + IconButton( + icon: const Icon(Icons.subtitles, color: Colors.white), + onPressed: _showSubtitleBottomSheet, + ), + // Chapters + IconButton( + icon: const Icon(Icons.video_library, color: Colors.white), + onPressed: _showChapterBottomSheet, + ), + // Fullscreen + IconButton( + icon: Icon( + _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + color: Colors.white, + ), + onPressed: _toggleFullscreen, + ), + ], + ), + ], + ), + ); + } + + Widget _buildTimelineWithChapters({ + required Duration position, + required Duration duration, + }) { + return Stack( + alignment: Alignment.center, + children: [ + // Chapter markers layer + if (_chaptersLoaded && + _chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: + _chapters.map((chapter) { + final chapterPosition = + (chapter.startTimeOffset ?? 0) / + duration.inMilliseconds; + return Expanded( + flex: (chapterPosition * 1000).toInt(), + child: const SizedBox(), + ); + }).toList()..add( + Expanded( + flex: + 1000 - + _chapters.fold( + 0, + (sum, chapter) => + sum + + ((chapter.startTimeOffset ?? 0) / + duration.inMilliseconds * + 1000) + .toInt(), + ), + child: const SizedBox(), + ), + ), + ), + ), + ), + // Slider + Slider( + value: duration.inMilliseconds > 0 + ? position.inMilliseconds.toDouble() + : 0.0, + min: 0.0, + max: duration.inMilliseconds.toDouble(), + onChanged: (value) { + widget.player.seek(Duration(milliseconds: value.toInt())); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + // Chapter marker indicators + if (_chaptersLoaded && + _chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: CustomPaint( + painter: ChapterMarkerPainter( + chapters: _chapters, + duration: duration, + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildVolumeControl() { + return StreamBuilder( + stream: widget.player.stream.volume, + initialData: widget.player.state.volume, + builder: (context, snapshot) { + final volume = snapshot.data ?? 100.0; + final isMuted = volume == 0; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + isMuted ? Icons.volume_off : Icons.volume_up, + color: Colors.white, + ), + onPressed: () { + if (isMuted) { + widget.player.setVolume(100.0); + } else { + widget.player.setVolume(0.0); + } + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 8), + SizedBox( + width: 100, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 3, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 12, + ), + ), + child: Slider( + value: volume, + min: 0.0, + max: 100.0, + onChanged: (value) { + widget.player.setVolume(value); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + ), + ), + ], + ); + }, + ); + } + + BoxConstraints _getBottomSheetConstraints() { + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 600; + + return BoxConstraints( + maxWidth: isDesktop ? 700 : double.infinity, + maxHeight: isDesktop ? 400 : size.height * 0.75, + minHeight: isDesktop ? 300 : size.height * 0.5, + ); + } + + void _showAudioBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: _getBottomSheetConstraints(), + builder: (context) => StreamBuilder( + stream: widget.player.stream.tracks, + initialData: widget.player.state.tracks, + builder: (context, snapshot) { + final tracks = snapshot.data; + final audioTracks = (tracks?.audio ?? []) + .where((track) => track.id != 'auto' && track.id != 'no') + .toList(); + + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.audiotrack, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Audio Tracks', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + if (audioTracks.isEmpty) + const Expanded( + child: Center( + child: Text( + 'No audio tracks available', + style: TextStyle(color: Colors.white70), + ), + ), + ) + else + Expanded( + child: StreamBuilder( + stream: widget.player.stream.track, + initialData: widget.player.state.track, + builder: (context, selectedSnapshot) { + final selectedTrack = selectedSnapshot.data?.audio; + final selectedId = selectedTrack?.id; + + return ListView.builder( + itemCount: audioTracks.length, + itemBuilder: (context, index) { + final audioTrack = audioTracks[index]; + final isSelected = audioTrack.id == selectedId; + + final parts = []; + if (audioTrack.title != null && + audioTrack.title!.isNotEmpty) { + parts.add(audioTrack.title!); + } + if (audioTrack.language != null && + audioTrack.language!.isNotEmpty) { + parts.add(audioTrack.language!.toUpperCase()); + } + if (audioTrack.codec != null && + audioTrack.codec!.isNotEmpty) { + parts.add(audioTrack.codec!.toUpperCase()); + } + if (audioTrack.channelscount != null) { + parts.add('${audioTrack.channelscount}ch'); + } + + final label = parts.isEmpty + ? 'Audio Track ${index + 1}' + : parts.join(' · '); + + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected + ? Colors.blue + : Colors.white, + ), + ), + trailing: isSelected + ? const Icon( + Icons.check, + color: Colors.blue, + ) + : null, + onTap: () { + widget.player.setAudioTrack(audioTrack); + Navigator.pop(context); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + void _showSubtitleBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: _getBottomSheetConstraints(), + builder: (context) => StreamBuilder( + stream: widget.player.stream.tracks, + initialData: widget.player.state.tracks, + builder: (context, snapshot) { + final tracks = snapshot.data; + final subtitles = (tracks?.subtitle ?? []) + .where((track) => track.id != 'auto' && track.id != 'no') + .toList(); + + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.subtitles, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Subtitles', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + if (subtitles.isEmpty) + const Expanded( + child: Center( + child: Text( + 'No subtitles available', + style: TextStyle(color: Colors.white70), + ), + ), + ) + else + Expanded( + child: StreamBuilder( + stream: widget.player.stream.track, + initialData: widget.player.state.track, + builder: (context, selectedSnapshot) { + final selectedTrack = selectedSnapshot.data?.subtitle; + final selectedId = selectedTrack?.id; + final isOffSelected = selectedId == 'no'; + + return ListView.builder( + itemCount: + subtitles.length + 1, // +1 for "Off" option + itemBuilder: (context, index) { + // First item is "Off" + if (index == 0) { + return ListTile( + title: const Text( + 'Off', + style: TextStyle(color: Colors.white), + ), + trailing: isOffSelected + ? const Icon( + Icons.check, + color: Colors.blue, + ) + : null, + onTap: () { + widget.player.setSubtitleTrack( + SubtitleTrack.no(), + ); + Navigator.pop(context); + }, + ); + } + + // Subsequent items are subtitle tracks + final subtitle = subtitles[index - 1]; + final isSelected = subtitle.id == selectedId; + + // Build label with available info + final parts = []; + if (subtitle.title != null && + subtitle.title!.isNotEmpty) { + parts.add(subtitle.title!); + } + if (subtitle.language != null && + subtitle.language!.isNotEmpty) { + parts.add(subtitle.language!.toUpperCase()); + } + if (subtitle.codec != null && + subtitle.codec!.isNotEmpty) { + // Format codec names nicely + String codecName = subtitle.codec! + .toUpperCase(); + if (codecName == 'SUBRIP') { + codecName = 'SRT'; + } else if (codecName == 'DVD_SUBTITLE') { + codecName = 'DVD'; + } else if (codecName == 'ASS' || + codecName == 'SSA') { + codecName = codecName; // Keep as-is + } else if (codecName == 'WEBVTT') { + codecName = 'VTT'; + } + parts.add(codecName); + } + + final label = parts.isEmpty + ? 'Track $index' + : parts.join(' · '); + + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected + ? Colors.blue + : Colors.white, + ), + ), + trailing: isSelected + ? const Icon( + Icons.check, + color: Colors.blue, + ) + : null, + onTap: () { + widget.player.setSubtitleTrack(subtitle); + Navigator.pop(context); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + void _showChapterBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: _getBottomSheetConstraints(), + builder: (context) => StreamBuilder( + stream: widget.player.stream.position, + initialData: widget.player.state.position, + builder: (context, positionSnapshot) { + final currentPosition = positionSnapshot.data ?? Duration.zero; + final currentPositionMs = currentPosition.inMilliseconds; + + // Find the current chapter based on position + int? currentChapterIndex; + for (int i = 0; i < _chapters.length; i++) { + final chapter = _chapters[i]; + final startMs = chapter.startTimeOffset ?? 0; + final endMs = + chapter.endTimeOffset ?? + (i < _chapters.length - 1 + ? _chapters[i + 1].startTimeOffset ?? 0 + : double.maxFinite.toInt()); + + if (currentPositionMs >= startMs && currentPositionMs < endMs) { + currentChapterIndex = i; + break; + } + } + + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.video_library, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Chapters', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + if (!_chaptersLoaded) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else if (_chapters.isEmpty) + const Expanded( + child: Center( + child: Text( + 'No chapters available', + style: TextStyle(color: Colors.white70), + ), + ), + ) + else + Expanded( + child: ListView.builder( + itemCount: _chapters.length, + itemBuilder: (context, index) { + final chapter = _chapters[index]; + final isCurrentChapter = currentChapterIndex == index; + + return ListTile( + leading: chapter.thumb != null + ? Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + widget.client.getThumbnailUrl( + chapter.thumb, + ), + width: 60, + height: 34, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ), + ), + ), + if (isCurrentChapter) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(4), + border: Border.all( + color: Colors.blue, + width: 2, + ), + ), + ), + ), + ], + ) + : null, + title: Text( + chapter.label, + style: TextStyle( + color: isCurrentChapter + ? Colors.blue + : Colors.white, + fontWeight: isCurrentChapter + ? FontWeight.bold + : FontWeight.normal, + ), + ), + subtitle: Text( + _formatDuration(chapter.startTime), + style: TextStyle( + color: isCurrentChapter + ? Colors.blue.withValues(alpha: 0.7) + : Colors.white70, + fontSize: 12, + ), + ), + trailing: isCurrentChapter + ? const Icon( + Icons.play_circle_filled, + color: Colors.blue, + ) + : null, + onTap: () { + widget.player.seek(chapter.startTime); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + String _formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + } +} + +/// Custom painter for drawing chapter markers on the timeline +class ChapterMarkerPainter extends CustomPainter { + final List chapters; + final Duration duration; + + ChapterMarkerPainter({required this.chapters, required this.duration}); + + @override + void paint(Canvas canvas, Size size) { + if (duration.inMilliseconds == 0) return; + + final paint = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + + for (final chapter in chapters) { + final startMs = chapter.startTimeOffset ?? 0; + if (startMs == 0) continue; // Skip first chapter marker at 0:00 + + final position = (startMs / duration.inMilliseconds) * size.width; + + // Draw short vertical line for chapter marker (centered on slider track) + canvas.drawLine( + Offset(position, size.height * 0.45), + Offset(position, size.height * 0.55), + paint, + ); + } + } + + @override + bool shouldRepaint(ChapterMarkerPainter oldDelegate) { + return oldDelegate.chapters != chapters || oldDelegate.duration != duration; + } +} diff --git a/lib/widgets/server_list_tile.dart b/lib/widgets/server_list_tile.dart new file mode 100644 index 000000000..54274e12f --- /dev/null +++ b/lib/widgets/server_list_tile.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import '../services/plex_auth_service.dart'; + +class ServerListTile extends StatelessWidget { + final PlexServer server; + final VoidCallback onTap; + final bool showTrailingIcon; + + const ServerListTile({ + super.key, + required this.server, + required this.onTap, + this.showTrailingIcon = true, + }); + + @override + Widget build(BuildContext context) { + final isOnline = server.isOnline; + + return ListTile( + leading: Icon( + Icons.dns, + color: isOnline + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + title: Text(server.name), + subtitle: Row( + children: [ + Icon( + isOnline ? Icons.circle : Icons.circle_outlined, + size: 10, + color: isOnline ? Colors.green : Colors.grey, + ), + const SizedBox(width: 4), + Text( + isOnline ? 'Online' : 'Offline', + style: TextStyle( + fontSize: 12, + color: isOnline ? Colors.green : Colors.grey, + ), + ), + const SizedBox(width: 8), + Text( + '•', + style: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Text( + server.owned ? 'Owned' : 'Shared', + style: const TextStyle(fontSize: 12), + ), + ], + ), + trailing: showTrailingIcon ? const Icon(Icons.chevron_right) : null, + onTap: onTap, + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 000000000..d3896c984 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 000000000..129066438 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +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 "plezy") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.edde746.plezy") + +# 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 new file mode 100644 index 000000000..d5bd01648 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +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} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..1e491337a --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,35 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) volume_controller_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin"); + volume_controller_plugin_register_with_registrar(volume_controller_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..2843f641f --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + media_kit_libs_linux + media_kit_video + screen_retriever_linux + url_launcher_linux + volume_controller + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 000000000..e97dabc70 --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +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}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 000000000..e7c5c5437 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +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); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 000000000..419429478 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#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) + +// 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, "Plezy"); + 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, "Plezy"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + 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); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), 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 new file mode 100644 index 000000000..72271d5e4 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#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/macos/.gitignore b/macos/.gitignore new file mode 100644 index 000000000..746adbb6b --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 000000000..4b81f9b2d --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 000000000..5caa9d157 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 000000000..88d089360 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,34 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import macos_window_utils +import media_kit_libs_macos_video +import media_kit_video +import package_info_plus +import path_provider_foundation +import screen_retriever_macos +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos +import volume_controller +import wakelock_plus +import window_manager + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 000000000..ff5ddb3b8 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 000000000..24704c75d --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,91 @@ +PODS: + - FlutterMacOS (1.0.0) + - macos_window_utils (1.0.0): + - FlutterMacOS + - media_kit_libs_macos_video (1.0.4): + - FlutterMacOS + - media_kit_video (0.0.1): + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - screen_retriever_macos (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - volume_controller (0.0.1): + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS + - window_manager (0.2.0): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) + - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) + - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - volume_controller (from `Flutter/ephemeral/.symlinks/plugins/volume_controller/macos`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + macos_window_utils: + :path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos + media_kit_libs_macos_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos + media_kit_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + volume_controller: + :path: Flutter/ephemeral/.symlinks/plugins/volume_controller/macos + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d + media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 + media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd + wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..e4284584e --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,800 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 0222508F6EE2A41A69A621F6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0B314F22FE6D00D0C7673 /* Pods_RunnerTests.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6AC86ED62EA70B4C0067BC66 /* plezy.icon */; }; + A3BC036FAB4B8E39B7126970 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 202C1CA899D4FEDC5F1A3000 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 053753D375D440BB1EC6B342 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 202C1CA899D4FEDC5F1A3000 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2BE1BA94C203ACCD120F0127 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 2EDB448AB472FA47ED7BA245 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* Plezy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Plezy.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 5CD0B314F22FE6D00D0C7673 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5F3D7650F7C0FBE711045DD4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + EB84EE33D6E7F697C803E14A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + EFA1AE35E23B7995D6C62CFD /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0222508F6EE2A41A69A621F6 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A3BC036FAB4B8E39B7126970 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 295400F6A94D53E806ED64A8 /* Pods */ = { + isa = PBXGroup; + children = ( + 2BE1BA94C203ACCD120F0127 /* Pods-Runner.debug.xcconfig */, + 2EDB448AB472FA47ED7BA245 /* Pods-Runner.release.xcconfig */, + 5F3D7650F7C0FBE711045DD4 /* Pods-Runner.profile.xcconfig */, + 053753D375D440BB1EC6B342 /* Pods-RunnerTests.debug.xcconfig */, + EB84EE33D6E7F697C803E14A /* Pods-RunnerTests.release.xcconfig */, + EFA1AE35E23B7995D6C62CFD /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 6AC86ED62EA70B4C0067BC66 /* plezy.icon */, + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 295400F6A94D53E806ED64A8 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* Plezy.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 202C1CA899D4FEDC5F1A3000 /* Pods_Runner.framework */, + 5CD0B314F22FE6D00D0C7673 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 2CF269D31FC1E8FDDD181D81 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 99EAC5D8B082B1DBE925E26C /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 1061B01F01C2AD298FA23F27 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* Plezy.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1061B01F01C2AD298FA23F27 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 2CF269D31FC1E8FDDD181D81 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 99EAC5D8B082B1DBE925E26C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 053753D375D440BB1EC6B342 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/plezy"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EB84EE33D6E7F697C803E14A /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/plezy"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EFA1AE35E23B7995D6C62CFD /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/plezy"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000..9917e837a --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..21a3cc14c --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 000000000..b3c176141 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 000000000..80e867a4e --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 000000000..2a199ece8 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = Plezy + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1 + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 000000000..36b0fd946 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 000000000..dff4f4956 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 000000000..42bcbf478 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 000000000..78c36cf44 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 000000000..4789daa6a --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 000000000..3cc05eb23 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 000000000..08ba3a3fa --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..61f3bd1fc --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/macos/plezy.icon/Assets/plezy-cropped.svg b/macos/plezy.icon/Assets/plezy-cropped.svg new file mode 100644 index 000000000..d34219dd4 --- /dev/null +++ b/macos/plezy.icon/Assets/plezy-cropped.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/macos/plezy.icon/icon.json b/macos/plezy.icon/icon.json new file mode 100644 index 000000000..3af70ecd8 --- /dev/null +++ b/macos/plezy.icon/icon.json @@ -0,0 +1,55 @@ +{ + "fill" : { + "solid" : "gray:0.94630,1.00000" + }, + "groups" : [ + { + "blur-material" : null, + "layers" : [ + { + "blend-mode-specializations" : [ + { + "appearance" : "dark", + "value" : "normal" + } + ], + "fill-specializations" : [ + { + "value" : "none" + }, + { + "appearance" : "dark", + "value" : "none" + } + ], + "glass" : true, + "hidden" : false, + "image-name" : "plezy-cropped.svg", + "name" : "plezy-cropped", + "position" : { + "scale" : 2, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 000000000..f9a394667 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1179 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a + url: "https://pub.dev" + source: hosted + version: "88.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" + url: "https://pub.dev" + source: hosted + version: "8.1.1" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 + url: "https://pub.dev" + source: hosted + version: "4.0.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "4e54dbeefdc70691ba80b3bce3976af63b5425c8c07dface348dfee664a0edc1" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://pub.dev" + source: hosted + version: "8.12.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + dart_service_protocol_shared: + dependency: transitive + description: + name: dart_service_protocol_shared + sha256: "1737875c176d7e3d87bb3a359182828b542fe20a0b34198b8d31a81af5c7a76d" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + dtd: + dependency: "direct main" + description: + name: dtd + sha256: "09ddb228b3d1478a093556357692a4c203ff4f9d5f8cda05dfdca0ff3fb7c5d3" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: "direct main" + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + sha256: "3c46c2633aec07810c3d6a2eb08d575b5b4072980db08f1344e66aeb53d6e4a7" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + url: "https://pub.dev" + source: hosted + version: "6.11.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + macos_window_utils: + dependency: "direct main" + description: + name: macos_window_utils + sha256: d4df3501fd32ac0d2d7590cb6a8e4758337d061c8fa0db816fdd636be63a8438 + url: "https://pub.dev" + source: hosted + version: "1.9.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + media_kit: + dependency: "direct main" + description: + path: media_kit + ref: HEAD + resolved-ref: "5d002a6bd9c4b4247f83f1c0719f8144daf87f0f" + url: "https://github.com/edde746/media-kit" + source: git + version: "1.2.1" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 + url: "https://pub.dev" + source: hosted + version: "2.2.20" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: e9a21b6fec7a8aa62cc2585ff4c1b127df42f3185adbd2aca66b47abe2e80236 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + screen_brightness_android: + dependency: transitive + description: + name: screen_brightness_android + sha256: d34f5321abd03bc3474f4c381f53d189117eba0b039eac1916aa92cca5fd0a96 + url: "https://pub.dev" + source: hosted + version: "2.1.3" + screen_brightness_platform_interface: + dependency: transitive + description: + name: screen_brightness_platform_interface + sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unified_analytics: + dependency: transitive + description: + name: unified_analytics + sha256: "8d1429a4b27320a9c4fc854287d18c8fde1549bf622165c5837202a9f370b53d" + url: "https://pub.dev" + source: hosted + version: "8.0.5" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: ff4d2c720aca3f4f7d5445e23b11b2d15ef8af5ddce5164643f38ff962dcb270 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" + url: "https://pub.dev" + source: hosted + version: "6.3.24" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" + url: "https://pub.dev" + source: hosted + version: "6.3.5" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" + url: "https://pub.dev" + source: hosted + version: "3.2.4" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + volume_controller: + dependency: transitive + description: + name: volume_controller + sha256: d75039e69c0d90e7810bfd47e3eedf29ff8543ea7a10392792e81f9bded7edf5 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" + url: "https://pub.dev" + source: hosted + version: "0.4.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: "direct main" + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 000000000..284c66c02 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,58 @@ +name: plezy +description: "A beautiful Plex client for Flutter" +publish_to: "none" +version: 0.1.0 + +environment: + sdk: ^3.8.1 + +dependencies: + flutter: + sdk: flutter + http: ^1.2.0 + dio: ^5.4.0 + json_annotation: ^4.8.1 + xml: ^6.5.0 + shared_preferences: ^2.2.2 + dtd: ^4.0.0 + cached_network_image: ^3.4.1 + media_kit: ^1.1.10+1 + media_kit_video: ^1.2.4 + media_kit_libs_video: ^1.0.4 + macos_window_utils: ^1.9.0 + url_launcher: ^6.3.0 + uuid: ^4.4.0 + window_manager: ^0.4.3 + logger: ^2.0.2 + +dependency_overrides: + media_kit: + git: + url: https://github.com/edde746/media-kit + path: media_kit + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + build_runner: ^2.4.7 + json_serializable: ^6.7.1 + flutter_launcher_icons: ^0.13.1 + +flutter: + uses-material-design: true + assets: + - lib/data/iso_639_codes.json + +flutter_launcher_icons: + android: true + ios: false + image_path: "assets/plezy.png" + adaptive_icon_background: "#ffffff" + adaptive_icon_foreground: "assets/plezy_android_foreground.png" + macos: + generate: false + windows: + generate: true + linux: + generate: true diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 000000000..d492d0d98 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +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/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 000000000..3d42510df --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(plezy 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 "plezy") + +# 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) + +# 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) + 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) diff --git a/windows/build-installer.ps1 b/windows/build-installer.ps1 new file mode 100644 index 000000000..ef5371b44 --- /dev/null +++ b/windows/build-installer.ps1 @@ -0,0 +1,123 @@ +#!/usr/bin/env pwsh +# Windows Installer Build Script +# This script creates both a portable archive and an installer for the Windows build + +param( + [string]$OutputDir = ".", + [string]$Version = "1.0.0" +) + +$ErrorActionPreference = "Stop" + +Write-Host "Building Windows installer packages..." -ForegroundColor Cyan + +# Ensure we're in the project root +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectRoot = Split-Path -Parent $ScriptDir +Set-Location $ProjectRoot + +# Define paths +$BuildDir = "build\windows\x64\runner\Release" +$PortableZip = Join-Path $OutputDir "plezy-windows-portable.zip" +$InstallerExe = Join-Path $OutputDir "plezy-windows-installer.exe" +$SetupScript = "setup.iss" + +# Check if build exists +if (-not (Test-Path $BuildDir)) { + Write-Error "Build directory not found at $BuildDir. Please run 'flutter build windows --release' first." + exit 1 +} + +Write-Host "Found build at: $BuildDir" -ForegroundColor Green + +# Create Portable Archive +Write-Host "`nCreating portable archive..." -ForegroundColor Cyan +Push-Location $BuildDir +try { + if (Test-Path $PortableZip) { + Remove-Item $PortableZip -Force + } + Compress-Archive -Path * -DestinationPath $PortableZip + Write-Host "Created: $PortableZip" -ForegroundColor Green +} finally { + Pop-Location +} + +# Create Inno Setup Script +Write-Host "`nGenerating Inno Setup script..." -ForegroundColor Cyan +@" +#define Name "Plezy" +#define Version "$Version" +#define Publisher "edde746" +#define ExeName "plezy.exe" + +[Setup] +AppId={{4213385e-f7be-4f2b-95f9-54082a28bb8f} +AppName={#Name} +AppVersion={#Version} +AppPublisher={#Publisher} +DefaultDirName={autopf}\{#Name} +DefaultGroupName={#Name} +AllowNoIcons=yes +OutputDir=. +OutputBaseFilename=plezy-windows-installer +Compression=lzma +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\{#Name}"; Filename: "{app}\{#ExeName}" +Name: "{group}\{cm:UninstallProgram,{#Name}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#Name}"; Filename: "{app}\{#ExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#ExeName}"; Description: "{cm:LaunchProgram,{#Name}}"; Flags: nowait postinstall skipifsilent +"@ | Out-File -FilePath $SetupScript -Encoding ASCII + +Write-Host "Created: $SetupScript" -ForegroundColor Green + +# Check for Inno Setup +Write-Host "`nChecking for Inno Setup..." -ForegroundColor Cyan +$InnoSetupPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + +if (-not (Test-Path $InnoSetupPath)) { + Write-Host "Inno Setup not found. Installing via Chocolatey..." -ForegroundColor Yellow + + # Check if choco is available + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Error "Chocolatey is not installed. Please install it from https://chocolatey.org/install" + exit 1 + } + + choco install innosetup -y + + if (-not (Test-Path $InnoSetupPath)) { + Write-Error "Failed to install Inno Setup" + exit 1 + } +} + +# Build Installer +Write-Host "`nBuilding installer with Inno Setup..." -ForegroundColor Cyan +& $InnoSetupPath $SetupScript + +if ($LASTEXITCODE -ne 0) { + Write-Error "Inno Setup compilation failed" + exit 1 +} + +Write-Host "`nBuild complete!" -ForegroundColor Green +Write-Host "Portable archive: $PortableZip" -ForegroundColor White +Write-Host "Installer: $InstallerExe" -ForegroundColor White diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 000000000..903f4899d --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +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} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..122493b4c --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,29 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + VolumeControllerPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..dc139d85a --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 000000000..a6d435b30 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + media_kit_libs_windows_video + media_kit_video + screen_retriever_windows + url_launcher_windows + volume_controller + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 000000000..394917c05 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +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) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 000000000..6947dcae6 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +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" + VALUE "FileDescription", "Plezy" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "Plezy" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "Plezy.exe" "\0" + VALUE "ProductName", "Plezy" "\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 diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 000000000..955ee3038 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +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); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 000000000..6da0652f0 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +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_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 000000000..7b4fcdaaf --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +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); + if (!window.Create(L"Plezy", origin, size)) { + 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 new file mode 100644 index 000000000..66a65d1e4 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +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 diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..bb4b6c513703d3096561532c8da5d6fd18704690 GIT binary patch literal 2859 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 literal 0 HcmV?d00001 diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 000000000..153653e8d --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 000000000..3a0b46511 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +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; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 000000000..3879d5475 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +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_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 000000000..60608d0fe --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +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)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 000000000..e901dde68 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +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_