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 00000000..a11ff7ef --- /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 00000000..79c113f9 --- /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 00000000..3f600b8d --- /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 00000000..f288702d --- /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 00000000..8c6789db --- /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 00000000..f9b30346 --- /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 00000000..be3943c9 --- /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 00000000..5b48e762 --- /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 00000000..399f6981 --- /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 00000000..60b14e5a --- /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 00000000..c545cdce --- /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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 I|Mt_r08rZchX4Qo 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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 + + + + + + + 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 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 W(haYQuakZOl4+^yt5vJmefS@5v?}oc literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..4ccdb0b78b603147a6593fb3f71f54699bc01453 GIT binary patch literal 12176 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..cee25601dbfeebb6b493fa1307926234d4f20cc9 GIT binary patch literal 17602 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 + + + + + + + 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 00000000..5f349f7f --- /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 zcmZ{mS5T7;7KT5H5ReiDrHMa+bdV6Fg%*)&loA3corTbw5JNEmL8?lT4ic&{fQX>< zE=UR00KzYV^niq>q7dq`vokxhv$GfHJXg=WGw0mA31%jSoa}<^003|r8|fiW3HjH6 zSWdS!BR%dE;u!tA7*}Tu8s-v+J_P`UK$YbcA@WcN3aSKChQgGTWFZh31mdLdi}GKB zufMCOTj>86&IKZTPX(#}Bw##!(Loqz-+=#hlokIS!j~0z2>{p)jP-7yLh`z8*iaVM zf^7 zFxbX*ES{MYA##NU5-rbW-1lem_IV~(agqL^`8*v3GX5lZYxigXKliAoLlorZQneBr zZR{X)B>YBF7i5MEGW?$7{(v+_-tm?iKkVM-vYS=Slu-L6B-`n)7p69B2xb-m01lQ% z;1;~A)CcXrU+LA3;%+JD2Xd!mV@J@xi&*ByqYBf;W`wB&d;+?qz#}P#TMyqoo!KdF z`0`RmM(EnrmDF8GLlkvB$X-1Rc=ng?M(g;9vX#j>Vi(9Pgmak>3@nv`yC1BVCr>Yy z;Mruk_>f$$cr8inMCgwyrucy1>=@m6kIE+PuN-l;-}t^qT=h#`Sza1 zz=l`nxwU{8mS^+Fa~?6>PJ{KabX+sG*l@c2vh}f2A}Ye1WnP0%0uR8?XkjRZbnN!< zZtcd^j&gPl^Wa;zPH@W zZ};IvFe4Dwx!PP&rSf}!tQV@&Id#UxzSsic6|RXgQt#(MoOwLWpwF?EW0&)CJTGz_ z>ldm`An4PUEQsKKyEjAjq{gKD%Te{o9RRcHU7MWwDzV^*^- zLq~D(-ohA_>nDJPYdpJ!HVY|DNyW6~Wx9Fjdwp&8_z^rXabiwx2*8D#H9aPN&pzI9 zmU??zz=hA8SLa2#10?`!dO7s3G)Y&1My6KLi^k>-hcoZg#S?wI%eNYAZ0D>^0ul*J znlgjTPZi6&mfQ9eVsadY*H9HGmBb9y-N5kG-^=qkAI+^6Ib&QBI4-OYml;)fEhA0j zK_)`p?#eexe0txU-Tu4gA1yTZICg2)a2zs2CbbC31nkF}1MK|k2Qn+mzHZ3)-O4*@ zy_?G2&Vv_*MSy6Y^_3ph^TE>T@kPTD#*tOoue*l(qzZ14o`~@J>zntPn(wLti4eg#SB;^r zo>79rbL^33@F)=yb%bMH1=WH&^bY2D7zsXI6SLRsQIiyX@2$)L?dE%jp6R5qiobz{ zS58^x&ac!e1ZoL}jrKE`-?-2te_TR^ZMdU_T#su}{lLPps0d|ZbZdSszNOpxBsc5c zu7u-R;1jr-KnG36y(UeHfkfTq>)N4RGZUjH*umcJ7h5{E4joP>WrZj$S;J#ea=S_9 zt3P}tH{Ztc7>pn%oSQHuuF;1pxV&MqML`bs)xb+nMfzkS>MajM4=vW^4H^pl{hg~Yp=Uh?jeGFcs4~p zm5bUiY0qfkIai^VtdGKT1N6^N%;dg$8uM&Ij>Q_DQCOM|8SRv3_E*IxC^)w^C&oC= zZoOEjnm`p zWPRlPd}LwfRu5gfL+U>_cj7G!a&pu-E$8DrR^gVJs2Yrw2NPf>%cJ@$TKVgHN4b)k zz|VpYDl~UQfait%f&4Lm1DNX1kIS{}S4^49a`sJoRV) z_bUW^q_~Ydk)*5TjG}j0wZ+#_t;plpq6T4L`M19IUWROMvtRz42Tznf_)=?{X`97Z zh;(c5d<|TN=5|)^4b3l0&M1N1=%9Hw`z~6M^g;rYG-tWKks8BEVC;$MzB1_+3X?bI z22W``E-u}paUgZ;IWkuNX84`?E_F!SQhmz@O~rXoKkmR+%-@48o!Nlxs@lACUYOr# zpw6U1oqJZqEx&nGUzhD@*NTwQkg#c84E>UMWo53?YU}Cg$&E`_lZ-mkMhb4apoc|Z z*UZ;$2Gc3y;)T9s+~orMk^Zp~hEj`yYnJ-9S&^wN$eGEmo=*8I zeL*kUJjUg3gp%@+5qrcMYBs#M@&=PLFK~&ade1F)D>Nf}e;{3g|JmpHFASt4CU|58ffvCS!mwQq%W^$fzuGqGr)I zH$#H%lTV*bxgf4$Ac<=!4LY4e&XnrW+$CHXBC}Fv!aH4DQ5rM-&9tSrqDus1pcILm z3PC^o*_WU{Sa%+uL@NS+8u$=dla!CO`XEU?gMPO+^FVyC&Je>@AoCc$?&|r38(aNi z|9QzBHI^Gyxs9X@g)n?g1B||g=t1)`mV^mD<{X0i*aHN)p_YpW3$3W@C~>8shsJ2= zJdu5$GCn%>R};%uBxA`)499K|-21iTe}D z+9@!`vaDO#n9hNnFw>|!18|VZd7~GNiFug7t`7rCv5tDsomOoCwZ(cJT3pm`mcMce5LJN|-nEbtQ&{j3zZ9r5)2Y%>`lv;7DKKu|4eO>%Hz zz=G|MH;1@nJiI2J5+=S8dZa3kGan03uM}K6k^ParPb7Q+b*ddaf8J!3|6yL|C&yna&QSGTWWDP{3H1Pe~EAi)-bW^sZA4{nPSU?I56Vu3&)xVr~SfWA6{g-K<0{T&$h}z|YGs_?D0NEkCa|KTuSVUla)BGKJHwstyi=l?H{ZE*?$FBRVC{mXb;`k}kvHnP-^89n9$z}6I2Oh?Y7nYRzV5g7R zOp+e$cj(y6rtPm#m5%*$J}{cL`-$xMk9)7J-H9EGqylg^EWw{I-Fe3C=#d@|jn%au zzhWf(#RjP3?pyVqT0uiI^ow-I+BlF6dU@LF-baD8sc}t%QN6szLR<_R%kAwFseHRS z>YJaiJDoR)Ui!KgAA3pItf}2++Bne7iaF^z_-y@6@wQ>cE&AOiHnA8s0YJ-g^v!+} zzxa(8fvDE;E0~%^6||$TYlR>udo%w%95lvBkP{uk1U5|er#RDCVwZkBWSKYT={tTDD! z%o;G?$m2cNMHEW{n5e-hEQ6FlD#j-~gH5`?j>-`r*nv4(4Ym!nNg3DbpcR5VogZcy znok8*CJ*(}&NhuPq+%`z7LOAO*?aIbYV3_Z7TtdMMi0%MBeJc?YkC1`%TW#CN)Gc_ zJBu|tK8E!bJ1yLa-xW$@bgu_OA%H+J<$1ecy0v=38`kTPi|h0^D@cQLkdI#tzm@P4 z4Y`}SN8J1hOG7+4h-;&y&SFh8ESGBi8yy5_2B3UPk^aFmVR_OOI9hGEi8l3?aWkaj zJ=?|Oh(@F?zp|R#in4x+sqs^B2=Zt8`^uJ0$Tc@ITnX*6r{}~ zw=3w(J>BmH!)bo5oz^7J1d8l!a`-z4#hR0pARt4^b7Ib7S~6T?MaAAvvbl)0mx&;lz!0&>6`PPC z?Zmx%>}k{cF!M?68%DLLJjSt(Isrh))xl#6g&SI&3y^e$WbtO=vv9(z;3GUXtj2(Pgk4`a;@2xIOQobCy{v#7 zbRf}$Cvbm0&V$&|L-`qXJRC~@UVwk%WcA&C@mgQyL3?jq<(mpS)07`X+w^#X z+xU|%#DS&0B)5P8^}@nUC@T8~P7Fs!a*eC=Wf9s9Xl@5cTkq?peo?qA^4f zDF{tF2-7Sq=`1ZLkDe`Dg9p;&9{edrTTaab9DXs3>fr)mW@CWYN@;$6xuE74IbYA( zS{lmt=OI0TR-`$4cMOhN5O@DDjXG=DE>9^*L-t)rF?>^o+Rh0ZHG1&<3c26Zs8wHk z=wYRy#ng3_C2rYY4q%m83qz|48Q^WvdQU{(yg_((aAlt$jVPkc#kq5A(Z7{kc17*s z(aoT16vi=53GK$vC`n{rBdS3a(_hLEtpwYQor#Ko*Dn{lR)ut)kIH5KA$*Ez!Qlow@u&85dTKm&r*oeE-OrK%c18N0X z{o#)kt+fa#-_1YjR?q1S+-E}>WrldV&(JEw^sB?2KF*23U-*oD42R;f09t)sj|PVbz|JejI! z95`N`vc=tW?s9gn33f503TB<`h4$o?&|NJHz1wY2doZT3ZAtT_rPpH>IH1S$Tmx|~ z9JIs`nO2IXXWo?zb>>Cnd=-CI;JP$>CqHYatm8}ZF5 zHV87|Kvho86@EV7_FgvtK5Vf#TQOCTk>7N1W3K;p$(4xZU-lU3j5RIo+EaWz70at# z>+B@$)l63=unvy2A{L1&xBvW_Q%p*Xx3fL<={Z5x4FIK2w@o}*<>>D_S2e#Y*g)!h z3-y+gh=FNcSS~-IKkmH3zf$sT-j5@Mp%M3dGX=u}rA6bGXINkP2G`tA)rIb2$v?DZ zk{EI+Nc%o^O9$K!0Ary<5fL;5?w+YLq~+?iPL(&g!Id18I0{WN(;?*D@t`L(!aJFKm#>^f{*+QPG^m`6M{(U+vsvFP=$_34i608n1baO2mB zqm1TdSImt`Dh@KPrYtQF@ci`1fbM;|IG0?b%ZNirRs|LyNY&TgjB{~oW-S}|#=}GJ zKDBm|-uYJWb_PEPM<&DIA~I(hx{;1tdEQSVWm2WR5ACY-PXhcWjV#X+(f)nd45_?2 zCNX+i747PI=*tKMj1M_$T``F~u9#{O`mJIyD6?aml_h8yxfTPTk zc%xyU5nE*w`-1~J;COepM?nkdE$;TyfgE zF{_Ki)G`lWfDJPP#Z1|WGT7uh&`E`Q&hfFba8Eq1G%m_~a6qU{oYevE;GyMtDJDc4 zFV16NQ>#zdSC1XEU(#BXX}WKh^uy)-MZ_;9QJo(C_GB_? z7L||gs5gFDFuwXuuml`h{^H|G^PxykKw8%KB>{IbP8N`D!G3O=ZLfoI8LIJW{so5a zfE<;ZWnMsU{&a;-DDpUGrQ7icws3LmbUH-vx`!*3wW9aoft>5;xM4C{s$ur@8X?{Y zx^jICtwS-e0YpMMNehFF)IVHb&rqnWR4({`4_J{%*VVr}L~YpZxjdZWB)imXv#Wf& z4?`DOa(fc*bYGguhMYU9q>U-~=XdE8Oj3>J?ENz%%P(I4kqbq~pM4WXqXs3<9q7Y~ zuM^s`w-NE${My+?{Q;Bi@24n};`TCCuF~@1s?wA*GLEE`_+DO50UHsl1y@w0$THzC z8zGj^rPx1IM(JW-ZJqABiX?{j+|ujcXINdqOv+_e!GF{(W)KRAVsR>2;<9%2HfqC3T(T42(C$;r7rBy-GkDo_@ z0`u$1!NCqpbL&&TvD0oOf2R|jGK-_x)`y!*aaW-JZZz+jeYP4UbQEXDFKJfY3?58# zLz2dCAgaLP3un_@&=`P19}uJ-6;O}4ct|PIyj+{ERu(EJ{Y!<1f&okBM?2MSEc)f2 z&P~+EMfiAa;7Db80s5<^`*b7j4(s7|^wlaSAK$U`wRxKmvTuK-SwO^nFLND*;ax>9 z^1Eh}Z%+0d-Q!VNe)jx>UWi44NLFW*YlS4d-*Xm!eZp6pl$~PGRf=W)N7t6A;S#X3 zyBZ#17m0;hkX>QU;B%mXh_|Kj`fN98Oj1iJr<=TScU}GgCK8gM6`QDN!IdhevcQWc3zz3I=nuM2F z^4`mO7r?|h$>y|g)wr-~{~mZLCJr}wF|1#L)|qi((3%V4$IDQ2PF>hUP2aH!ot2p4 z)%L|*+e$j_*8+W0EeKp3wm7O_sIH$YR`E2p#s8@`@%yY{CmOqECpwjO>tQ+sDzrny zdIGb=Il!IB*41Qtq0&w&bSCLFP$mVrGPN~OOq#Q$5|?bh_P#nZ-K-_F%{Db)dlBG@ zxZT=u{d2a6|I)AzLE?Iyi#RAx9y+iw@dbG==0b*dt}kN>CmDdF z=NUN#%P7pu=zzr8NTAMR;Lr$LFO*_n=XuukWlo<-u$q=)W{D0hH)Vc~xs=N))>eUX zM#yH&t1tjE;L^%h(-;WX+K|?5uKi=*R7@#+Hz*!oSiLQQsI0~K@xh&PX~x4suLe)a z8TAD_ru*U<&*@@IfeR-@HelkwnFs4&QZJ3|HE7_e_wCBHmIE@}pbWJ(x+lwG8`Yiw z41X6t{5^@6ODrwE;h>~{W%M$fM{%PzU>XgcvNp*6q|A^ks3Xg3OYHwplfB&U>1u!? z=p$#gFn~h5JKO7X7&eaC8{o^yF5XaHMOo=yeOuo;&J7}<-kBD3_?&xpuddtTj2UAc zEGzM>V0Lv>Z*R}-Fd~2)a*h4g+`9+ou~o^|?{QKzP?~ks4fixqo~DZXR|Kn9&8u4( zG1#!5GnCcY>3oOq_{A{Z~!*Z-%gYVt#tye4a z`om@OZ^x zpTKBg$>n3d`dp^c;2KPb@vgxqdOsS54a4LAqk)336DIujm!GfGajy{2P!y8_)?-c_ zxFHVOQV|_piL%-#t!kVWR|-rceG&r$w&wYji4f7l3q~4jFR0Z1HA@3zf=ztj8yy-e z@d5E+bo9~vDzE@zD1)-tyI~TM+#ik(P4p4(M@eS-Dgt|qFY;GvMp?B(dqCg?;aw~< zR^Gx`pzX1LpY*RfXEi^Mo}86G%Czo%1a_25;N?$)RD?`{oj{so#zL1*vt={)wEgie z*YiU)Wl>keQ@o@iVS*4v8(b}O*1_giK}X3!a|)Ao1#>(#M-Rx^RbCoH<{MXk0Z+R! zh3KLAhK9JHysZ-6dMM}qB46}vmi98XB;=F-J5NJe{JpXmn~tk?kHmD9 z-|I}(8(-9xL4d{fN!xgtBBvf%FPR7@4|A)F69#_&swtn06@PLqdx{dIgCNPxQTip| zWa@VnzuZKYbr$=izQBUx_5*uDM{_!+6cTO@CtSQ?`1Hn}+Zm5Zt%U5*-WDspv7qOM zcV?mzB+RS5?5spNK)2N<62Q#v>H11l&e-OKAEgpBO+eMJGdRz{W4O915XSk=!eoCr zeO`Y5NsMAcg>qd+dS(;<%^?$00x-sanA+M8&AO4W`%$30_{Tc>`lGo~2*+c|yoS-^8$ zqVv`_{5~ZwQ3@$`#Lzvl4;ZZsiENVC!WN_OH&c-k?A(-oT*QGJ3!ygNKESwB8O&@W z6*9dzL;|f;BG)_qJf~caf@@EOyAg%KCJjGqFOp}sXe`>c8TeG5-ZAxU?qTpPs3P?5 zbvoeGl*;U=-{+cDw-MVPkNrs+Mi8pa={mS?# z^fq<>ZSfY^wo*I!$=9VF$vZ8EfJcsTHDanO`xL#ojPPY|>)8L-Wc6H7#i@c)y%K+G z=aoM{J>30svEFO{h(@%6~^d1b-yx;cJc%DBU0E%*|vQ^S%(Ek7@ CI)thK 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 zcmZ{KWl&tvvi0Buf=nQ2V6fm8H26S(!C}xaKnTGZEWsgIf)f}d5IjI|m%&|wyAvS6 z-F2`xw{F!}_0_BQnRfK29-FPpQ9TX1e{|Dk^rHDy7perqkccU|-;bLDFk#Veq{ox1-X`?65v!pEq-h z*h&G|hmRm-21bwOt4&;}m*3BO{@r1pQK>7*QL!yLy8uQ{BPzpax5^hX-5sC9JVf6( z(Mmb^1Tf$NaE9@}=;rG>3o=C(F@334Jv7ziBlstTkFDnGLAm^5ioB)$aevqwhv?|} zwv)BA!Y3(gKtROf3)uip0xd!hL0>`=L99o$0n}- zq5^BN#Ax@D_i2}Wwx}ov%Si*m($+7w@WBgdcsKxsZkaasqC^>}fq~%9*+q``wH{ZX zXa&o|xpN^eC!F%EjKJF+IlQllSi{bBCo-?gj>MRGh-ew2MZ)IbTG%)|(#-BGy8DuE zqjR#e+cMksYHia0`6SyP*m|@l;B{57 z{21|I5-F4UAtkP!pgoiCd(aHatZYDe?n(WRm1yb{597;2w~i+gT%JU3YD+eh-?41) zXb#{1Yq1WxvJF2&Dga}3ke``oO>%aM)1{d*g|QhH&QPzN(l+Mri!*;e*}H62lVP?| zZ1OCP#~cWJDl{z|t-0-itEV@P5d_V7xeUl@`5Nw@2ZoF97S%37uB4?_M!o(O-F_S_ zvnE#%@LDr@kE=y|eirsFFb@UGu!;0R-cbrh0MsCzlaSKGDOAIqo3YwCo=Lj!wZdD! zjg59l$*|_>09nHOP~I`@LiA?sqzs?98{_0;<7BQX>0SWaomm13t{0QleT1yLzjJZm zgi@@8FKg)()-TJn}vp$Dt4MiM98 zCDmbVf|3XFKcu~@8Qe0V8cVE5>9>E_F(DAw3t)_U&MZo4*lsXX`f=#W#jt)?_HIIz z(_U|zpqU>B)jA7=rJYSw7g;P z>9D2cX$mNDUlqT6whb)aVTfDx9q3=~{aYmN`*UDRZP<*xQz+4-_rhmiekSv+vaQ0< z;$7ONbKqBoa{0mL4fZE=vD?hxmxGw!E@qL_Q;nt6`;}Lrts{e?!K~#Ha}%aI?e-=y z0b_w$f4EgMqBGBdhWASI_rmH#O-sLg9-G=ny~aBi$5&*AI4n7A+OFueTKM<%v7}1_ zXF}D~L2gP$*b48iW)DZ{DZE6auiA_LUY9D=To^A)nE1(!0>oMoB4enwx4@yU0p z!vPp&D7|E$d0vs;!pv>iwM@D5+Y8f7P+g+6j=`M)dGG`X0dn{T(JE|DBg8X#J~UW* zmYbj5zUFya52`!2x06j`!u4GNK`-}9Rz=-QDHs8+G<=US8$0+vX14S0Ns+ip6Mo_jKx_q9igULc+lZ!v{9;fy zk9a~1rP_Se(UVMhBzyOo0+2xgg5I12%W;Ruz9{9&TKpJy7IFZg2~QnaJf7rR;r0u^ zzwtRg9djsKwE7l~7?gxaIWHd7EDVMAAWrTJ_!EN@7DF|LkF<0drXQ96m=5Ne?|T5p zXbPs&wSrg%F_9P0y(`%%#Bw_l2=#YZ7<7U}Nj#6Q_OQe+E^fn3?H!c1_$c|zh}sl{ zVF-!)RW7W;@`Ul|Vq?}ju|gAZV8J3E z$zx|BT1Z7!pcU6=tvZkpI-3l|Xn8E4uqu;EFHP-|vtwB#{*%|pu zM0GM|00$_bFAPpgR)dP&-+J~N4uQ*~@EK^Ty~RpWwyl`$BC!vBV=qND$Ijm&75>T# zsl3->xVNeur(u|aOxY5(=iH;y_Ow=^(Sdq-Z~IPKV+Bvrf=42C$WLX*wmCT9sCTeA zc>rFvI!@E>+9v{wzllcY<3vi(;ct9T!^l=!8)bEf-hkN~si->AF&g~7=Hv=Pip-yv zUqv9Ds<*l2SV?1$Z{$L`6MlG+$sbpul0BJtufTtQ1&TG5acf^Bijq{&duM|+dbfa;Pt4}1HjIbTGrP3{X2=eZAu_aoi zwKF}H;w;S|KtmXo>%puXoFedzH~kG7d*m*5YS-O8kM|8+nYwuqE=PkHJ(3S$n1%2= z<$cPF0&qqLysQ0v+Lrf1*sBnOiD!4)j$sg0nnbS-b?p zxA7H0{rCr)0}I~zHnIwyiejSD`5`|I4QI8|OuNWRUpy}=W=g5WZOqg6P9cs~^gg^V zQNNR}5TPr{i*Wt*3sLb~9tw(m;-E;7!gF5(uf~;W`=0jry9n>2%*IDgy&TOtEU-9p zrJHt3CluQNlodQ(6Kfz4@z|0A^L&;2#YkQ#=X?M*{tEw9+{#`-ArsbtnlD>DOf!Ae z@v0%ZvFKjn4+S)JVbbwLUUYHvGUVkW6?x*PYxOz>C1Fw#1vtBnA5`}v>o#;X@0x)3S5xRou=?=tHJ7rV8htG% zrHD1;%ddZGQ*7altE{OXhp0A)9?d5^Gq0dS&xDOy@h`)p>LJLd+ z=5|q=N~T&M;D6In|)x;j`;?*lka<`?n_x%bN&=h@!B*Wj`W<$~_p zSd2Q^xq4|9v){uxcagWhucQtwO14&F9e}pt*{X2BnnJg2q|M0XM`MYRX>afMN~>|^ zd6t7^~*6(JiZEijM6b0JYZN5Ai{w_LR!XcvVqympF*ve@W}mZT=E*VOoK!2m5i ztCZsOVjd>c&+x>&$)mM9N8V$X#-?@0{Rl00uL$jLoVe>%^xhYU?*xZmdpth04wRJf z=x(aF2k_b3!CXZ*^YILO7;mLdrE<%&91%Y(l_5Y|7*@TjyetUalmPa=L6q~&P1Fcq zNssGLkI4uPJZa?x8lCJkTjq6J$y`@5rE{P+Td~DxT+=u_c$ocQw=ggy#mQ=PWI=NoTkEVJj>;0UG)xupr!k*q5s}SfcW$OY8gdP) zis6$)H9|uQmiSoeZbnw;z!*uqi^?()(+gt%N+rKgBbFD(2Z~R#s}$;XZ|*v z=c)~2J<1nkFy%7;q4}r8`fhaOvl@xceQUP1<{q={4e@t2Qi^VJ1DS>&dY|s?VE>4U zj4H^TZxYNO2?+6um^AF^(cf_-`D@xHyK|`*BJI6o zN{eo3x_qGxs)vev_#+%T*ZabK!NFq}h|RCD)Di_xGk*k|T@80hdfdX2)noxotxTVd zxH`Zu_pbIOyHs-F3O&Eg{(= z!^!fOi3)YUYX6qzZVBNB-I`k6Rav_pxd>cZ)$^>HS^a%{e_1iNXWIiwT#j1KL1Vs8 zicBicG^krk(F&tI5E!aV(uzR`h{W6%q#oj?>soLa31Xe58qdO_UP1ZSt2= zRVVV~x(XOCLM^ub*I%SgtMLSC*%yOA-p#4OeX$TX8w`M_vRt&ysLsX0u(jca>>%hv^qw$ z@}|W>ja7A-L=qOy_v}NR!o)yB$C+bu+7I24V3={iF9PIo_s6|D@HR7gMWrcWX|fY& z=j=!cFA_M(8_KsXgin+8lofXaUFI%G1pOt>q|vk0hm$g~?v6iK?Mc!GL_*UC?#Bipu3hJk%Ga+f$=H^9Vz+cHjK;tZ0Oq z5GKGy6T%HEE`j&WfifEZDcN7*a5k;_?^_Ad^kg%KO!!IO`fh&^e(cyc6Zxefl`&*X z3TAuxost4G4(WElP}lORNAH&%P4K7ZTVl)6YqLb|GR3^v-1DySg;lNJ0&y3OhFS#S z6>CFWz<2_?>+<(er%sr+%MCeqx289*HEvs-5YOF17Va(2fzqW}^V2nEkKLOuP!hab zUkWaxxVWA9RHZAeNEU)sL>=l8&7KP;p?snRbTdDMHetvNY zK4iW86JZ={73Kr4*#LdwvKK3zf5}AQk21fnn(|7^tRf6WFdU-0yOBWfhc@brTKEXt zTho>}+15h#oldzbX9A1nmUL-3QWJ8331A^q=gyU^{2|a1pDK$zOITR%`PTzxZ<7lV z(K+NHwDWSLf+rpIXdMX0_4(Y?yRv2g2w-hb6_K-&i9q4U?5SE@JW67gTaZVwHQ`+c z2M4&{zu6QAw4YXWlTO{3c&k+*LkdPt4_k-xQ)3?uM48*j^>X?TO${riCIw!L{QLUm z!QINV4?_3pwxezA{CopXBo9PXS+@-w;2mUWB-zcsKoaA|&E#a`_tb&!mWKA2NeF)- zj{HP~KB+t3r7cqT(6v69LQke7Uqd|Ug{Q_p$=fbyUIw$8HskZ}LnbJoH=T=qe(X)( zvo@$iemv$ z8W$Un$a#lhnwRq=TjCR!%syH9^@|!emsaGycyU3pJKTh+oPD-Y^XlER2~j}`6m4JdRevfy1BGSyA@y9PaiS5Z0vP(;6Y~ zYeVWqMM?w8ze}{i4t}<-#YRL^%cP_Zz%WiuLHLNg!}I4~4Ks+1^1S9s>l5@n_sO}g zjw4rh)dUt}vg&dx53JFIl>e?}o05_G^wU0}u#)|_%ozm+GgJg?? z^a3cP678`tf!79aOljQr7Rq+_t5ojbPuheTj`qL1xupdNVcoR|D;AJ z<{N}T!LL|0o~GPl_0xR69=3laFUPT^0p^GE zrVes_z1L8Pg+X=r@8#z5e_nig-Qu0O2t*?~+RPkV=2Kl+Cs7eERJJoGAQ%N~eeV?6 z=~1mx7ONh@{Bz{Q+l(sqbs`le1e68#WK6zaE8DV_ph^8s=@#b9%6K#*=-x1z0c}Ct zz^MH@U5$d051hi#Kd+i^&_N7#v2EcrVodPKep|miVWkY|J$E193trpoX^=Z}9idzH#>a5H{L_+X4b?rU+B7_3jiGXgR~NZ+pjbO#b!g z@p*hZmM$)4Ou5eo8(}L=Qi_yfeD)2}Kck)0=ByXfM=h{M_lmay7;&$v zv(@Whs#H!_7zuq_XeC!PUQk7AVNVjJI<6Fva1>M{kr{6xCHka_pX%@Xf<0Wo2lm#- zO;lc9_7s7m{b#724S&EhPV>wBMr^j3{G`x zeTx3Inm`^2ca$-4)RD)?h}LgPO;A4~y#I~e!uk09{zTW7HUWLdT-@dZ_*4#h>h(!% z3w3&1c~&rTT|F@=@NR0Su00pb&|U2p~mT`jp;C1Nbg09c~<_kPGU5mP#RM^T3!LP;o?qoO%Rh^$#&a3^;vkg=$Kcfdn5yOsOjw0TA(w0)xu#mwn)OXH8| zIYY?{iVggA zok=8p8!a5(SO(hlvMdT6K6pv8x>YSES>Se>eIql;Ck?8zOq%WOmpZ_NVneY6bUV5o zsSEzD)Tgz#kL8&5y(`mlzgZK0pvQ&=fkETyp7QY30Wn4$KB=FHqZqUaZkQ>Hp928TKc_2vcT@?sEF3lA`6k^ev?PPd{~a#qWJw{@b?q&7 zoI1Wa&G;l0w3)E9B2Y5BlDCj@cnZaSKS7f$y(&a7htW|oFFFw^XV%MPub8S8=e7J6 zggK}Mf*BE;pfm=b!jSWsq;RdFjn=p*sY5L9`}FSwnd-7(zLl#w17-IIm5=bfjv zAqnBw!}{hLUlLJNoMm-CSg_1kW`!#Di|o7B1N0YMRjG9S+%iCM)BbI|6XXjGpit?_ z6z1sQkjc}Bt(j>~*d zCsq8+qw*VKQ*$${+9+DE1W7_|TvWrE8L57nyCy>clEXx7OG&S;cjE9qR=>?HXCHQ> zP!|+?{?o(oT=m@Njk=o~x)n&``FC(ZVI#V}3dlDoR7(}wq%g^oC| zikP)Q5ji zLbf{TnQNCNjwIu_Mzm<8H+12)5DkzwdFEBI3%NF=h^hHGMQG9wSDL3tX z^mU)FFYjdaCkJn8mbib&DH5o1K_YLNovI$tlq~z0K=Pq&sH|-7m?zA~kZSIT_TctM zWZ^P9Sk`pF+7LYAm%Y>Rd-apw0&U`{5yXC%xTYdT`~FRM=okjUq}|CLh(~DOu1IEe z;JhQ8?L#$|KVn$im@lZvhmoZ^`jo zxPGrb3HMHMEy%C}vRY}I2I86w3>j%N83&u$y5~-Q$K`ZqzSsRMl?RaPcQAPg9IO{xrby8M($@pf_Rn&o0X+ z0hp!zZOKEW6t8Bc&k1N|_E1-TBbE7Nrll5RnHuOW z(?85_*(RPz&_*J{QYsh?q&yK=Y#)g4pan`Z(x$x8!?tBUn)nb?TY2V>yHFA2o#3(A%Os3gJ z!$Jn2)p3CAd}Gw9EkgO}U)`Y&?q5oZFPBWw{Y!G;+4ycSENx;|x+s`5o96AbuCAJ* zn4zD{LjS2xPD66MmLXY&en-?`QJFelF`T``mOO$cIvf9G(Uask8kj4M6(#EXWtXXMo!~GX1bSH(#$FWU2;(A)Atdanll^FDe~UC-2CuG6rUyer zcyt|0rY0U{uX3_Kpdsk%nZeEqx5iNHuxoLFpM!sBgO>!9_6@X+e%3!Kpc<={_eI@z zffSjSO4vCEPsdo199D+l=xC}b2%Sh7=t79Xg%?oxh#5e$^p}rHU>g}jNYSb&>*KS;=GD)3j62CR={RHYyC>QB zG1<(@Szf|NrPEq`U)Y=Rq&Nf0I%y5#bS=ZKZK23$%wROb(iGltBEedUksPXM zJcFn0ItBGPRGOrjN!lKR0e?`|Z7D2N8BzCWzR!@g#)%3JUN=iN294(nGh8@}s~cgD zG6ptZ7E|I6M*rS$kbD-OHASZ)PVilGFZRhGi!E0`7IR~b1@cfb&XI?qKr`chE=I**g*QXM*xv4dsX7 zZN`Yc9FcS*c#Tvvmg$t5)KPYb^)_=E->FZ#U?V%#AQ<;(x^HL66ef)!OCv}#e7P30 zZb)HxAI4d#jCdkDj*>8Zc}onjd^C=(zIUWNmUZ!G;DiRrEokb1I&$ta6J`Rs8OM=T z1_mAqIpr$F1^s#O_A5FqAC?bRhW}0L_eCah93O{Y3`giebhX$vSk%U-jIbP|VVE!Y zfpi|Jg%)NlUOYo0n1s$_ug`F^Dz5E6bws-;@5{hxf|1d4`(L>Gwy^r;30H3f)Ad1F z_>WdrDUYpRTb8^N)Z?-jI&`MmQ z??nNSG^8|8@Q73`kJ77PV& z6PJg*B6JHR4I=I?D^iuk7{CLJA6`p&{IxAj8}#|eFAg_E%hK3y+;=cp@ae*sZS01& zPMHqQ-9oj}j7O5BnN)6RgVclbpPgaxzO2?1b*lj_<16ifV}qyb?3dg^NiUKCIKJI# zM%Wvn776=OFH)=1Cm8~yRSF4*zD@;ZtS9`;Sa)nPM8bJ4-#EIkeDKA_-~9JJ{~pX* z)#6^g*|%C0@87d&Kjf=7YnE#3fdR@P{yw_#Ja{7kM6>_C_1Rx5!-F6ZD{{uSB3OMF zP+5niYUbE^)n5_C=1C%Ql-pQE$=+!RWK*L9Hi;o2De4eVRVSRhYLumF_MIk|lq|FCqW{>BeUv6R|`%!_9xcO_=zztskt4;4d#)3Z~O( z5wmoXxqDT7jU9=2QYLa6JKH+0BJvuF9XGSS2eHxS*!mNjR3a=^zI&S7G|kJ7HHLOj z)?Y3!;^c&Zn%*AR?CEqKu;S_jm#qu4zCiT9yUxkP+(mBiHhXD(VgNpb56$FKWp(r4IZA z#>dZlj1B_Eji0k7ek@N@?O#mP8i=x9*JwOe1TO~4ljcq{Hdu<%ZzU`pSMqO9PIh|g zl907Lh~UBwds9-f&7;qO0b$~+X{$7Tj`Tk$e`t63(irNx<>%D4rZp^XccykeVqm4a WtEfXI<PR9A|JZ?oiydxCe?uaVJoqxCQs(4#nLmP^`E+#oe7C#hv0Vm*3sZ&D`AF zA1^PHd7GEK-Tm&zHd0kt76X+86#xKW$jeEoL(hNyJCG5fuj*@dNYE3VtF*4GnTe~p zkg2md^aSAIJdoFtfJs`u{h;c2=~9 zHemcecW||~H+OM0v3LA`+wgw+UpFC4b0z}-_+90tBs4v<&kT{i;|<*VXBl|^3%U7< z3*se0YaMro z6T{ET{|;Q_&+qEEuiZDY?tQNpLmn zCZTC+rfE~|1XBy|&zdG^eQkWP;T;(?mr;O*{}T4#GQ}Nx9x3$uTx==bJ2zVfUTihi&riGt7oFg_3Xi_Dk5DvzqtWCkIVFRe2|iWAX^y z_TE^eSZb;X1Ma?p9jnG#FoVLn0{?vG4nhhO&Zh};NbJwn3u4z-M?n4sXZ~&mgmXAd z(g5*>4fm7I3w!=C4lwQRTgRdH&C|{3Dhc1o#MT0WEo#kWqu4u0aBYVH9!vC3;~lm1 zp(gCgNnKQcs}*vASWlmYUQ6ddvJ&Pu!ZQq|)=BMk!HzcL3P7d^eoA-A!7tvZ! zoRiHZF|ifu?3<9lCbMNfyBltk7Kgt5;VPTs=^$6r9k+Az$WN^!ji$O2e$!V zJgEdWtvC*u3=D1f%H42gY0HqR1^>Mnnej4MxfvOYLbzUU%VkJqg)p_l`!**%?>H+{ z?eCvJS}T@6v1-1r;gIwRY&WUXSY;#Z;W)&zds){sbSKC=&|ydrCT_c#0$u6tcUx;{ zmegNyWPQ|&*kI|rR!IYQEYL~Q`I-!RO%)tm!CINs*^Bsu&-2OaTRQ)=^<|n4&HjcQ z$Cvy$u@BWB7+C@cRPa$e=MIPfBxR#@t~G9rAba9(jhobFD*Vg$6tpLI?z@)SvtcJH_NTrQ&l71tWjg+MlCe=iTZs# zJ*jiP%{fGP?rV93`;e1g1kezvEXhYEYVJ-u3t=?0jp?IUM$5AKeQ9^EeAD{W-F0V~ zW{6=#Y@nMU`Rk0#o1T&B&-3{rMImb&TNG**Y!@}9nD!v|`IN8eXjyuNFKN&>xLKAH z=Ca`c$thTCgqoS9*)vNMfe!&c8dKe(#Uoz;{%`rd{SplpUXbdieh7FYH+~l*i-Pt= zoeGODDFq=s7jE{y=e;L~Ck`h8X=9-Vhwru0mKxZurxUe0z3Nr* zDcIp@m__&ighRS+Ts607A^e|x;3U+n&qEP3-RKtJgeIXWi)X1k#k>cBb!5-3)OX_svjvp6%xPB;mTMXje8Vl_ngF zpg;p~N0M-=X~XLK8Eq6)CS6ym7_J9>0UcfoWBVtkFRIw_Wt=5e6GOO$YJpF}SIh zcR_O^4Z}s4vper~yRpZK$p`i}NcBx0-wkrc(4? zPv5fLK7GOCW20g*{Vt&Zz$NtW$Bo_64H6een z?TiU-d436hh|&kY<&y?^6E#R;mxl6|wMn}kU?$`6rIZDGlG8QW&txqQJwZmQQuEII z5+5d-WKkH7Nn7nfZMcjsfDxjkL^wVwYZf_ z#u6S30aqqQC%|J)NE8S@{UzZ1B~ZP$7{{D59SBe*J+Kn9%-(5PIoNiA?dl-uDofs+ zrqsD3!$Y)TkI%OHkN|>by$a67;=`{5rte$VTO=m!h1&*y(}oYDHzN2OkrTWvE{d+O zEDtls21}-hf~pB`5fv71#einLL?V*5j*DoJxwGSGx^Q;zySfNZ3Kr>CATT$dJmkS^k$o$AYn&*2pD zXGLfCX?2Sgp-R>la0N5s1?Vy+8jk!66drGVCR0`Y2QLqgjS(@wZa;&Qj8esI>4|n- zu|)g;>Et=&&7n@qnz`?jn%EH!Ff*?Z4(dNyr`FeoP0@SfuRocDJw)Y&40;dn0-3BV zgKVJ_~LzFNYc|>3tFgy6ChA!7$eyhCFpfX&W`JJf{lFKM1Z!UU4u4;t03`0$HGK=Quo>A;Z5 z1#czF$xFvORKlcD`~@jk3(My)jx{g-`TmxV_7E6CD=kcLt%(6V`d72%|9bWccRp0P ze8afqDN4vkhme{o#kGTEu&mjE{&bDR7)0bJyn3=MoXc0gxE6iaoj!b~EdfvR3@h(P zZ(S+zGfLc-H*@!OFHJ%gJDHF##VD+Zx+RPFhv0k9@bGiSK)0{2u#KdYgfzS3ANT>f zg#Hl|PbuO}^(f!GY5iv3yz>_rFnLWRm!K_%!WL4$GyKRS9K5EiaJoOX*g;GN;kUXw z)~?s@iV~FpkTZQ$yuN&KlQauva6&rC-z%geWrayLSk~rH)eICFxcKuBftP&J5G6JE zneS7OxAsr$ots8(Cy%{-YDf10l1NrxLVn^^^17CRXuU8~@dG0$u-5YeDC9OIXM2i{ z35adV+7Y_AA0|f=a+_Zrq^er8v)}OqrVVkJm<*g}$iDsWo{sRKxWZ;i4r;WBqc9$e zoT1u|2u_$JefQ&WwsUkJxqQ^=TcS_ZChTKD4^yN*YLR}>M$e*fG|`~;6ECvDNwll{ zaV#fX--?39;74@9d3mDL<0N~M^>u<9)|w*{elRNkPfZ#1IsroXe^r;rL?hTi)|>3H zf6T&9-0G@H65IM6Im@VWy~_F=Ed;m*9SzjYa#T@(KUNTeZ?=^!HP)63rS=eNUy_mz zt|I)NSvimCjYu)V)DKRb!q1!2UOg+-lVJP`%V2eKjFuXvT(ZC!D{)rY_zzi8%41~D z#&<0(XqT-%-H#mKT)ZpuhyJ-MK?}^>DCZuM)P0(f3}L%8n;Luf9A4nTCZ;tXvn5aR12 zoG%7%`)#!s`Ljg}#AD1sYD$%XKs#owu$D~KOrUCS6I+z1 z6q&9XJPv!sIiZ;HiKXWXn*doxt^4hIsVT=ZRbWHCMA=ie*&|HVFIqyr%F@65#rv8| z<_B6=(PJSP1-l&Bff~(Fn8mv|duU!*ZEjgmxdjhsON{CdRT_z1TRj#*^9QEU{A<(m z6M5dOd4!S~ri;X&TRYXJ^@2Me*EUhBXBPtcv;|S*F&50&NfwMa=HIc(OjV;a_1(o` z<)}Bj{Ej!A+=u@x&4sK&Vn%E`ZyUa21GK!s3Cs5=Da7Ur_9n*!Fu|64P_(uGc3NBO zF^i^B-A>s2iME@3!BSI-eDxO^+2A~hv<1hb2WYqksG%V^(%k?VmDX+!XNv|Gk<@z8hv{Q9> zTU?)Vuk(7QgWklS#a`#S8`!L1&LciRrgNC2~*yTM0#O}R!mmUOtmc zW>oTo^o1Br4Vf9P^7mLCP=e-EKxGO`xnaR$m_=1%8Pe(*|a64y~Hoeje*L*q?6P7X#Ftb{TIFrr)}(Zf~J-c-(_`p9vkVl zx7UQ@6it2*i6XRKq@9{F!rGc>K-r+bnf8Wt#^Tdz@PBJxt#`!OtKbsN{^lgBGBuNX zC={Hee*e)gNrgr4@GDvSo&Q;VIsB0~h=gFZkmnRdsq2EanP>YD(xu~lWaAAJ11(^U zgXcx!N79F(SQe7D{=KOl3a8A63TJe2Mpk#f%q=9pkpDiHa;UJk zvibItO(8sjw8NL#s(eRB^tGwhks51_i z-_ASv;&*qOmACUX7)c#DmLfaB}Gx_|(w- zx ztMi&e1K}u*(F0210s=OdH51j*IMBDZ=zo zOSf}xv2l6j7CCgLxbf}EL&*4>5Ad*p4i#l5eEQvT|Q#U_5IV%^I_^4h!f_vJh-CaCVjjkIiF=Cg~+GYa8*AxjnW z*vl!!y*l<6k%#@0nij0Q{8T2vniL!yK#AVgHwSBf`Uxb&M@R371bb3 zs+2vuQ)|6ZpEXfPzD*69bB|(vn$_I#1&+Eb_?+|EV!_YATMzWR5$~25+FocXZC=-G zJaxHnyysw-v3v-#%&xGkLFc2bW_=6>1`WN5`u4lA@#fmfE<~(RkMsRyfvp13F33w? zM{gnxOfBOkK44}%yVw(R6UJIhIVYGO?hYYkF)!Oj=-wueFM}Sf8jz# zY+XcYoG-E8O_U}>4pNZjDvR36QCY9*a>RThAoOrbyo6YU1WH@q^ge0EST?Oqz4r59 zW(l?j<7&U>B)&(0b0njg=a48mc`wo~Uz=l{>kQxl<%w=Zx$YR->+*K}NnqKMM#SOL za{q>1@7q_Cg$RwPB85-TxHI(L^vOA&Bo5& z_zM=Vpo&y}V1bay*?ja*#RCk-wnp=cB@}pxA7&?|HFI1u{y5i7Y%L^RvC6M=%r>KH ztQ-qlXn`M3EX!{cairB8JTU2a6J6)MZX z?ED!Cu5LLw8Q1%W=aLA1UhRQJ8FY^pSMZdq_%dWwT25)!yaC!ixf+2^aPgTClt)7X z)!LI?7j1Fx*Fas-?3i2nwe-i;YX=8e-|y^H|6nrOOsU#Rx%`*Qo;85~9DpWUlxZ<{ zVM3B-5NH9WUH>RAjm5)9m>W5|T>I$M+oK>sbB0w-uYw>Wlo>~-&U4z8pS)hR@G9%| z1~xB0c70nF(s>~$dE5h~qIVHYpfN;q5`RgnHk1LT`HKLJq<^X9wal0BJ!(~hn`);3X z(37K))hLG0*{@^_1+R(Ut^yw?={aC% zq@5mJAQ<+r(O<^h4|bE08N6opGu zd03a`(Jro}g}=b95&kAH+0)ytb-2qQQr5y&Bpcksuh~~zyh`$V@YLpa(YMoIuemxE z53kX<2p>~&gqzsuKP0M~o{jxlH@o!l;ot__1oUt4yEiixe?&HAJLXf?;Vo)5*RBVq z#_J`eY0aT!7A)FSl(La7&$8FTTg@)8>{NTGH&MyRQiw+?%<8f zjdHaK0dOrynI`Y2k9B!9`eK;ET;G7$? zi2aNgklk4P8#&4)I)B|>?PV8zndJCK2cL^;Z)-@lC=4d9JHb4vZpBbIiX+ zQVeggN7 zZIk^^Tl1o^c&LzzC*hG9KG8P9-f8&{s@uM}epwXsBh33FF!?j>Ij-90{hMVecX4Nq zUEEp#ldK>ZyO=Dnm^uQ;Ahlztb0U99hf*|4o1BO-x|sW<8Oj1eL^IKdKNsJ2J4Mg3 zxze=f>)5|-e#c6yIg7*T5+qMj5{?OCf6^n;cOahgc;zIWQ<2_MI^r89VXY;IHbnX& zICF$nmmu2ITTIF4jM@B&90qX89AMUi3Kj0||2=$YlxNT8N^g}8(DKGBx69P&v8=Y;>~OoodUIU^eRW^4+i@(m?OL`#(-zu zUPssB(&#`P?X4>(+rK>`v@`)x1wTwL%hUI-HNSnGZS_>+oBs{A0}{x3DTMo-mc}RQ zLl()gOTPhHwr-^N)q2&EC~3dv8O-UIfy})Hoo&cd3VoF zuhoHmKAPX>?k0=Ha+Jkz?4YSq!fb8~c0S#6ksVvw3w;ZTIii(R!mdqWkhMWXtI5Tm zO8|Y2bvDr5PY|_7k~GW-nYHOXyG}{PD7R{sd-^z)PS&@|Sq?a|0)tUMf zrLA1>f@q?lY*9(sT&k?9PiI#lscI#Kt-7GTXT3^nlR;!vdVBoqw>cUzQDY`T84mK^ z^4-~DAWCzL=Sx70<$!=4s%<$W=-^3lH9L*5n*NJ#+QvGFll1j9;ijZ&bZ zJO+MmE)^`vq;Tc0Kh*d9`WRfgJ$i_f?d$i}|1d6N1cH>L&HPn!FRsRD{FGz&vrw?6 z{!p2vPkUKY{X=4ucx(i85ah5?F3E!&XjfqOU&Oz?NUxw}jiv_vM*dC-LxO99M>5p} z3JKyYFWsJPOb+VK+f7}UuPpu5IH?GDFO=F% ztd5N!Jo|ZPz7!UCAma|ryAeG-$M=bq#|wDQq~>`gp0#*FVenN99c}T z?@!g-8*jPTt;goG0Hr+%I@}$Zzsy8Cp%1aajw_eyO6M0_eS-l0;y59+)oA|Xppw<| z3(y(t9RD`^JUN?*Ok;0u#UoZ(aCbh*FoG6TAKgB&HHPV;piZ%$Jf6N~{yw&vkSojM z6+w#c2N00b>w0EvCsF!JEG0z@nz_J8H6tI9y(?O$esr^{rV*8fJI$9LYn%^a5ywGS zj->rW63~IfO0%}_aw%x*@0pDyZY~kbbt!9Sap7O)^?I*F!v=KDdE> zXqCV=7G;^G;WuU>F>V#ji4t$);M_%8S-8c_#n(>!`cw83DPGAX`g%d4e$O0vTq42x zB;)C#tWA_El@;E1r@f=KxX#lmf8lZ8IC_-@0+vcxT^G=@M|6KVvtbY{jXJF?9IPLu z52n1IBQ5nSLq5QU9gCPnQVsjF^H?lvCw6NMi!##B=s^oSKmS~>Ep6|a9Bv0pF^4%4 zJ_M)ch-LjGm*{|6>iEXR=^3>Vy_2SY9))vUEDted-616g090e~pgpv-cnZpCXFV#OGs#lLTA|a4FH^|Ke3Qr0lU?^3ZYy))R*z{w)n&C(Jzu1 zyc2!|G8>5cHo4OYMn&k&a;MME$;3i!0mQO4Htw2y;mBw5kjow(e0 zCVY)&ZIPU#lWV@EDVLyJizmsBaf~PHE6=-vlz?fbi>KqT;`zgZ(czZF;~~sBv});O z&4XICrkYj+zwg;Zx`snjj~1GFXmTgDUn+miE>C=b`WDmYclj4Q)i1kIAT>qxFuc{U>VGuOKg$!aN&J=~Y`9%m=#X8`jQmHp;xZBct?Md7V`If>4-GdTfk<%r;wAf<6z-PsRskcJ9A6g%M9k)4~Q z;S^KxH3CZJS0~4Pp!guI@N5Dov)Xi7DAA?qb7CEnb8sMZHzzR(c~@i+@a7V_b~KRZ zEhHy@`!Uv)q@x8iTMBtQP-@^6k&sbF-DIxewp$gvh+tnlMU^*b5vF}?=y(d>PceTs zip%tVIFnG3dJR5nKz%ewXro@6WE6Tho3Ko#25#r4qe(TC`1uTo7hlrd29@{^ntxgq z8SRl9vU)ufYxiaSA%@?av@BNWN`4vods3tJ`_hEXdOKK~#esg$9`w%WXZtJQZDjz{ zMG+VH_{*yl9j2Dx*p;EtN^XL8BazEBWOly``()q zQvPIj_1ZG4yiOJuh1s)n6nXwvDFJWihpuupMUEpfR0XIkeMj2r{mk7&yIecC4(+1) zHsu6TVkLIuMV07PjtfKWdiF$SwnDs=jNpx5KMJtg+?1=TIQ-w|U!8y*d-bLbF^|p- zXDd4~h)`8L0Ys~Zi0ZZQg7r?CKoPo|VyRK)uc@}Dp_2eRd=_E|MGBI$P&>*0lUZv( z&kEp=ojk4l)xweWtkC_!h}@FhNvRmpI#iBRKqH!BFk>{}gzD&|oHGYo=?y`mQ2Bn5 z;5xM7ZjHm%2K=bv$hltu{5o3wu8#gopl*cWQtA8 zy}s18O5Qwx#}`05dWNM|=d0RFVt{O9w}w3~fvzr2WTH(+TX_`S!l>`;($;NgFxiw< z+2yzpxVU1M4PKK$2;_GLftd9sk|t_#Y48C@*k^A+^Y-#W4_qMY{ zL`6mFNSD=TJ)+n}O)VIh5&7!s?}lQ}72SO%yVB&5p)7i`PwiR(^Kg8#+W&m6I;l*4 zI}CL^t8l&2BwpFVyG2*Y`U%gpF10EZK7Zs{RKM?u+Yf;MPn=(+rFr_z3Uz1qaw8f{ zVZ*YVVvg-a^w&avrMhEOgeje5W}^Ck&u;OAQcEwHm|nny1~p`3CxHWOg7Lp%Vt>Dolb-`@TDjf1;ygmLzX?66H5q48x{G4e66RGl@32qbS@T4ZQ6H(DJ;&3*`DofaI2SIM?qJ;$}3o`QCiZLM-(t&h;^O z|5kC`H(O2mMJ~gckN?`bJGpm*e&z0y_G=?T{7Y=jBi5;KzT0@DGm;9-!aTiJF(o|S)bycVc!1n%SSMd_!86e*%ZloFZa-^O8!}oxia(7^-*mmxC?X1XN{$E|GYvCvdKDvY!M{RShg}JYH3Wzto`Q-oh!pk^&i#tMt z4JJJ^h@Z&4Qk!UM8*YtJOxZ9}KHcq`?mr2faxE;LGWhw&(->gg2kq$+rw%Wnq1SL- zZ3<6<%vW;Ke+60WKl;?tEg;n1Z@c^YbAXrfHwO8>S|IjyjNc*&k)E`{Qd(5WwCQ^_ z_6|M`4n}Qzo5*~#zP(=FF0YBtt}87MlhJk&?)rFo6Z?K4cA#41Mk@V%DSACbuGT+z zTi>KV45H@puFT{)XaTtp4~Cy3MwRHeWQIoYUSwA@*dzcGOy&5CdWSYqv2wEx|4O42 z+KUys0J7LJ!g}n2W!3hLPvFy+0Y_|sQp z?LTO_Ucx+96;_{6wX)*j`_9WhO^g~q$AD_RG;irMlRUc`%>o8K)6-Uq81^U)AVfp0 zeEGUlk7Vo#Di8lizQ{|s7a?Pops@1tqo2ZNj;H-OfKH7{4F$<#L zRIcc-S37;Rn?+8gody?;hnm*<9Qn_Y&78=IuEJsWL>S?fd;u^vV7PWMV2@y=b}=7I z8k#Zss5VI9^tCkjnA5hN@ZQ$#Mw|AE&(-4nqq+o%_#ap>%N0JroYG(i!U3K}j054~O{QUcT_X zl#igR&3dgBty_ErD|TyD+Mb6I5VZc0ORK&ORxO)P?;~|Ak^58<+1OvbtsIV5$`*2~ zXrX0U?cvnIs6}ik-EZ_k(ZI@~O#%OhU42_DTa-sT4M7@yk~AYfr|X@5-kz0T$tq4d zh*s>8{Zo1{f9j|;D}G!-=~0lZgNiS?8*exVVk=qB%BE9aZRyRwIH2OY;dDQ%O9FFo z+s#L=%_Z~j`q~X!z*TbRndpbKLT=}@CA2&!p1T3!kFg?roiR?vSY_t$7NbgV9JzvH z-u=x2Rl7yBP6mPvV*O9e@6YyjUyLS26oI*g;!ARPb9vif(YNcjyXYLKI2yTcc(_X# z*{r!ZfKdB;HY(ssWyqq^hyt%UdUO8OLpYxgJM8F=lSX!w(nxAC^CdzO!UjvDfbQ}u+^z-U|o`Ijl zxMKG+7C8p5R<+nZ;#YEK@OxxCwQ>jg0EINOoefias(K6XuhYv$uRcs!Fk>4_X}E;Z z%OIsJPbE61HvAPB8=Ba&dkfH#nG#{sF!&CoT(RH|!h1Ib^E1WuONfMvJT3WFV&mfS zOTaLj`j5uJE}-6j!HsPkGjBP{E(pz8K!O_2ilx@1L&VxG<>dPp^{<>al$;`z!BNzV zi~pHS59W-(Gkpsw>MfE_HH+WgRr-t@4R`tZEh(U3<2Pf^Q55>;d^76?)Kt)8-rJ|%+ja~;yMVh zB1nJ+G7x`P{%E+$gk+r$vVPZ8O+XbTW3>#1Rq_MSR;KSl(^r^8{U13XV6>eYrs*s> znpB#FNFmKr?-QfY#+P{LqHg=9$lfw$*4&tef>$qb1R|#Vg?!o~?J4!Rx1o`D+A~8N zQ8)h%Z6b1Pg|u0_1OFH}Ww8(CCvMlo~omPp76Q9-{$6=^fhBvgBKU!B!oCWhFlfdRaKdFh_6(H7EUc zIt?stb7&KY7JY>tZi>rb>!t($Bx_j8(wz}} zQVn^r>8aH6$eoH~WSjzgNY78lP&jq@JOsDfTl`?JYPX!cxt#1zO@G}Eg(?h2jWzUI z(@^_I8JjlT<*v6l@m`f3NJ+b`Fv5|@Pm;etxb5t>Mv>6eUi4KLlMK1XSe}--_`~Q9 zlBbkjCIwcjbUrdv?G4^~fpPPjCQCJPavcj^b~{RO`;n&GrC=-vBW(W_ruD({{%g)1xS}lAfONQ2@FSIDxelE?b%L{I%|^iSt8NqBAHUmwGtC)KF66 zsm$_-RAt~GyG(l&Q%j5IQ*%nws9wD)tvLvmQrz+Rhn39L4ZPA!(7JVvDFN zUSJ|9q*!Os5Qvpo$0zW-psb-ypgWFeeonB}jy^xj9H738H{2?XW3IZ{6l$gNN@8OA z5~qT5V5GwKJ%Y~fLlg`zVZ?OxFBiNc77D(~?p*zg9hEp-4?KPzlp2I73AKDzRlde8 z18Dj_Te)=E>O=iAZgWx>H6V&7bU&5+Sy05yR9(*v#_`0G{8+f!u<+Sf^cx< z0OOfy3hT}nH{xmE<2$ZATetV~9j7UK)W0@mD?|NOIO$L&;ZfDk%MA$mrw{c!@7%Sm zCC8+sdy!eeiGKp%S#}uJ2im2{-R#~AMBa0Xsgbilqt(9%PKAhb9$ow}x*-&WPt1W( zpfn#xQqUxa==h&cfaeX1nj?BUlU&yZbYF|p%T?9Wky4%|OGbf41ZSNsy@TWRBVNF4 z&s?vHuG$QU@6W7YXj*s2mt}ar0e6ZbK4-UJ6jN@TLM}zZ4MMOhjT=AclQ97C2&Dy= z;W(n-o5Mgugv*Mr0r-C6U>+!{JNt`-ikxMkx%lcqDPpdt3-)RjLC=H}vx+xfL6@Oy zSR6e-dT15>##^YE40$WvLo37tP8s-qV*1Bi3oqy@`=2|z4Ud=Uvn}o;tEIc#ki$5a z1;paxCCrPNdVSJ#9b^JS64FRnStHH-Cs+b@BP99lqKuGMP3BS$3(Du9uA*;_o|euR zH9N94s?zws762*yZrs@wGKE@j0y@w*A9*F^RtJtoT?aVA7ERB>{b8MA7n(m8gUwgW zj+8f7Awz#ROn`>mpfOi9_qTlY>3BzGdY{L~knH#2(Wt&LIA(TL6x96R5xmtnFMuQ< zRBz|DL&c+3$9sf%`W#6bG>6u79wi6)IuXk2!wOBpyK5PSRR+ewt!`AJRPr*0K*zZe zble?bhui4lB5dt+)b03-PCZbyb)-T&AUwDMc6V_>w3&CB7;Sr9v*KWwp$z^SDc$F| zz8|xnIaoS*pqg+Ip z$c36UyNiS|F(FN8TBn`;WmuHW&W~4Fc6S;Tx`&{&<>gMcR1z*Ipsk`2Q6}n-{xV*` z?3*+)YDzN~s=pw1QfGZlaaT|UtH2Ez^d=q564J_AuaY3RGeU5D1uuOw!w1(EQB?FS zSgbw5pS1&8C3hk8j|ZBUVZ1AVQN=x2u;QJ3h{BYZu7aVQW6fxa zJ1tLy=XkY-$^uX}RW5?*SRrZ7=tHpj% zpaVdMFkD&1fjPhSmwSer}c*qAE`WpZQ2^nHXoAG}S`2p4w)F!fjRJzT~<-j1-Y zh^=neiT*u}gd6>fM0s@AKsoU%4F`4U9Y4pb6uMjH`dSbxC>1uskyXg|cS-9T5x0Ge z8lVmo2$1&^7qVl{qsYIc(_;7_qNJCqF>$c{2u%<-!OrvZ@K<$_ih3u(&=$Y;_ z6Yy2WsByA8O4bER*Wja6p)|bVRabM6oxNftkco&T0C|DUYOX09MR7+F;@gLt#cQzHymqp zfA?rK!XrjaS0S$OHtJxCDj5+N>F4xX_(HGOf{`kyuB-&QiYz-LnUfmsXal`fMEZKL7-V(=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 00000000..d8acb917 --- /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 00000000..ab983282 --- /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 00000000..60fb10ee --- /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 00000000..399f6981 --- /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 00000000..89176ef4 --- /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 00000000..f018a618 --- /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 00000000..ac3b4792 --- /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 00000000..ab39a10a --- /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 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 literal 0 HcmV?d00001 diff --git a/assets/plezy.svg b/assets/plezy.svg new file mode 100644 index 00000000..d34219dd --- /dev/null +++ b/assets/plezy.svg @@ -0,0 +1 @@ + \ 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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 u3+<-Fx3!X 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 E0zoh0D*ylh literal 0 HcmV?d00001 diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /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 00000000..7a7f9873 --- /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 00000000..1dc6cf76 --- /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 00000000..ec97fc6f --- /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 00000000..c4855bfe --- /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 00000000..620e46eb --- /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 00000000..63a235f0 --- /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 00000000..7c5f2ceb --- /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 00000000..919434a6 --- /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 00000000..18d98100 --- /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 00000000..f9b0d7c5 --- /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 00000000..e3773d42 --- /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 00000000..21a3cc14 --- /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 00000000..18d98100 --- /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 00000000..f9b0d7c5 --- /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 00000000..62666446 --- /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 00000000..f2e259c7 --- /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 00000000..f3c28516 --- /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 00000000..0fc3e407 --- /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 00000000..308a2a56 --- /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 00000000..86a7c3b1 --- /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 00000000..d34219dd --- /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 00000000..3af70ecd --- /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 00000000..b8cfb598 --- /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 00000000..0594a426 --- /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 00000000..91babd2e --- /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 00000000..7a74142f --- /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 00000000..4039870a --- /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 00000000..065e83e4 --- /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 00000000..f77a29fe --- /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 00000000..2ed4e9e0 --- /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 00000000..6f76a14a --- /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 00000000..10ee2f31 --- /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 00000000..1f77d21a --- /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 00000000..ad8c7387 --- /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 00000000..4d5d8f39 --- /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 00000000..1d0901ac --- /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 00000000..35b586db --- /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 00000000..d36d3126 --- /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 00000000..5c0e0424 --- /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 00000000..b6732ee1 --- /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 00000000..e92be9c3 --- /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 00000000..04839337 --- /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 00000000..d9ee0cc4 --- /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 00000000..f08b259a --- /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 00000000..8e576921 --- /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 00000000..6c52513a --- /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 00000000..1a254fb0 --- /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 00000000..7189ae42 --- /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 00000000..77533cc4 --- /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 00000000..cb2fcee9 --- /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 00000000..3ee66cfa --- /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 00000000..ca3f65f8 --- /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 00000000..a863ac63 --- /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 00000000..70a4b2b2 --- /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 00000000..31253696 --- /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 00000000..f5ead39c --- /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 00000000..0d82667f --- /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 00000000..82672113 --- /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 00000000..fd6eb531 --- /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 00000000..de07f8af --- /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 00000000..54274e12 --- /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 00000000..d3896c98 --- /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 00000000..12906643 --- /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 00000000..d5bd0164 --- /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 00000000..1e491337 --- /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 00000000..e0f0a47b --- /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 00000000..2843f641 --- /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 00000000..e97dabc7 --- /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 00000000..e7c5c543 --- /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 00000000..41942947 --- /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 00000000..72271d5e --- /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 00000000..746adbb6 --- /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 00000000..4b81f9b2 --- /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 00000000..5caa9d15 --- /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 00000000..88d08936 --- /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 00000000..ff5ddb3b --- /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 00000000..24704c75 --- /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 00000000..e4284584 --- /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 00000000..18d98100 --- /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 00000000..9917e837 --- /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 00000000..21a3cc14 --- /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 00000000..18d98100 --- /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 00000000..b3c17614 --- /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 00000000..80e867a4 --- /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 00000000..2a199ece --- /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 00000000..36b0fd94 --- /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 00000000..dff4f495 --- /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 00000000..42bcbf47 --- /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 00000000..78c36cf4 --- /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 00000000..4789daa6 --- /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 00000000..3cc05eb2 --- /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 00000000..08ba3a3f --- /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 00000000..61f3bd1f --- /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 00000000..d34219dd --- /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 00000000..3af70ecd --- /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 00000000..f9a39466 --- /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 00000000..284c66c0 --- /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 00000000..d492d0d9 --- /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 00000000..3d42510d --- /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 00000000..ef5371b4 --- /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 00000000..903f4899 --- /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 00000000..122493b4 --- /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 00000000..dc139d85 --- /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 00000000..a6d435b3 --- /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 00000000..394917c0 --- /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 00000000..6947dcae --- /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 00000000..955ee303 --- /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 00000000..6da0652f --- /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 00000000..7b4fcdaa --- /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 00000000..66a65d1e --- /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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 + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 00000000..3a0b4651 --- /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 00000000..3879d547 --- /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 00000000..60608d0f --- /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 00000000..e901dde6 --- /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_