squash
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
@@ -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
|
||||||
@@ -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 = "../.."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Internet access permissions -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
|
||||||
|
<!-- Media access permissions (Android 13 or higher) -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
|
||||||
|
<!-- Storage access permissions (Android 12 or lower) -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="Plezy"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
<!-- Required for url_launcher to open HTTPS URLs -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="https" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="http" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.edde746.plezy
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#ffffff</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -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<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_2" viewBox="0 32 358.32 399.86"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="22.67" y1="425.72" x2="201.83" y2="115.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path class="cls-1" d="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-.67-4.5-4.86-4.2-12.45.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95.66,4.5,4.87,4.21,12.46-.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-.66s-4.2-12.46.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95.67,4.5,4.86,4.21,12.45-.66,16.95Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>io.flutter.flutter.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 = "<group>"; };
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
67716449E04A72AA71A90156 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2C12D873DE8572579684A82C /* Pods_Runner.framework */,
|
||||||
|
3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Flutter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146E51CF9000F007C117D = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
875C8AEFBBA3759A1849D674 /* Pods */,
|
||||||
|
67716449E04A72AA71A90156 /* Frameworks */,
|
||||||
|
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146EF1CF9000F007C117D /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C147001CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = LaunchScreen.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1510"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||||
|
BuildableName = "RunnerTests.xctest"
|
||||||
|
BlueprintName = "RunnerTests"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Profile"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
|
</imageView>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Flutter View Controller-->
|
||||||
|
<scene sceneID="tne-QT-ifu">
|
||||||
|
<objects>
|
||||||
|
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Plezy</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Plezy</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_plexmediasvr._tcp</string>
|
||||||
|
</array>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>This app needs to connect to your Plex Media Server on your local network.</string>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_2" viewBox="0 32 358.32 399.86"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="22.67" y1="425.72" x2="201.83" y2="115.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path class="cls-1" d="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-.67-4.5-4.86-4.2-12.45.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95.66,4.5,4.87,4.21,12.46-.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-.66s-4.2-12.46.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95.67,4.5,4.86,4.21,12.45-.66,16.95Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> get _headers => {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Plex-Client-Identifier': clientIdentifier,
|
||||||
|
'X-Plex-Product': product,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Generate a PIN for authentication
|
||||||
|
Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<bool> 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<Map<String, dynamic>> 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<List<dynamic>> 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}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<bool> 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<bool> 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<Map<String, dynamic>> getServerIdentity() async {
|
||||||
|
final response = await _dio.get('/identity');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get library sections
|
||||||
|
Future<List<PlexLibrary>> 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<List<PlexMetadata>> getLibraryContent(
|
||||||
|
String sectionId, {
|
||||||
|
int? start,
|
||||||
|
int? size,
|
||||||
|
Map<String, String>? filters,
|
||||||
|
}) async {
|
||||||
|
final queryParams = <String, dynamic>{};
|
||||||
|
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<PlexMetadata?> 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<Map<String, dynamic>> 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<PlexMetadata?> 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<List<PlexMetadata>> search(String query, {int limit = 10}) async {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/hubs/search',
|
||||||
|
queryParameters: {
|
||||||
|
'query': query,
|
||||||
|
'limit': limit,
|
||||||
|
'includeCollections': 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = <PlexMetadata>[];
|
||||||
|
|
||||||
|
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<List<PlexMetadata>> 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<List<PlexMetadata>> 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<List<PlexMetadata>> 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<String?> 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<List<PlexChapter>> 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<dynamic>;
|
||||||
|
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<PlexMediaInfo?> 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<dynamic>? ?? [];
|
||||||
|
final audioTracks = <PlexAudioTrack>[];
|
||||||
|
final subtitleTracks = <PlexSubtitleTrack>[];
|
||||||
|
|
||||||
|
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 = <PlexChapter>[];
|
||||||
|
if (metadata['Chapter'] != null) {
|
||||||
|
final chapterList = metadata['Chapter'] as List<dynamic>;
|
||||||
|
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<void> markAsWatched(String ratingKey) async {
|
||||||
|
await _dio.get(
|
||||||
|
'/:/scrobble',
|
||||||
|
queryParameters: {
|
||||||
|
'key': ratingKey,
|
||||||
|
'identifier': 'com.plexapp.plugins.library',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark media as unwatched
|
||||||
|
Future<void> markAsUnwatched(String ratingKey) async {
|
||||||
|
await _dio.get(
|
||||||
|
'/:/unscrobble',
|
||||||
|
queryParameters: {
|
||||||
|
'key': ratingKey,
|
||||||
|
'identifier': 'com.plexapp.plugins.library',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update playback progress
|
||||||
|
Future<void> 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<Map<String, dynamic>> getServerPreferences() async {
|
||||||
|
final response = await _dio.get('/:/prefs');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get sessions (currently playing)
|
||||||
|
Future<List<dynamic>> 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<List<PlexFilter>> 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<List<PlexFilterValue>> 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<PlexMetadata?> 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<PlexMetadata?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||||
|
|
||||||
|
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<SetupScreen> createState() => _SetupScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SetupScreenState extends State<SetupScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSavedCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<PlexUserProfile?> _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...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mixin Refreshable {
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
final int? size;
|
||||||
|
final int? totalSize;
|
||||||
|
final int? offset;
|
||||||
|
final String? identifier;
|
||||||
|
@JsonKey(name: 'Directory')
|
||||||
|
final List<PlexLibrary>? directories;
|
||||||
|
@JsonKey(name: 'Metadata')
|
||||||
|
final List<PlexMetadata>? metadata;
|
||||||
|
|
||||||
|
MediaContainer({
|
||||||
|
this.size,
|
||||||
|
this.totalSize,
|
||||||
|
this.offset,
|
||||||
|
this.identifier,
|
||||||
|
this.directories,
|
||||||
|
this.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MediaContainer.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$MediaContainerFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$MediaContainerToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'media_container.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
MediaContainer<T> _$MediaContainerFromJson<T>(Map<String, dynamic> json) =>
|
||||||
|
MediaContainer<T>(
|
||||||
|
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<dynamic>?)
|
||||||
|
?.map((e) => PlexLibrary.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
metadata: (json['Metadata'] as List<dynamic>?)
|
||||||
|
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$MediaContainerToJson<T>(MediaContainer<T> instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'size': instance.size,
|
||||||
|
'totalSize': instance.totalSize,
|
||||||
|
'offset': instance.offset,
|
||||||
|
'identifier': instance.identifier,
|
||||||
|
'Directory': instance.directories,
|
||||||
|
'Metadata': instance.metadata,
|
||||||
|
};
|
||||||
@@ -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<String, dynamic> json) {
|
||||||
|
return PlexFilter(
|
||||||
|
filter: json['filter'] ?? '',
|
||||||
|
filterType: json['filterType'] ?? 'string',
|
||||||
|
key: json['key'] ?? '',
|
||||||
|
title: json['title'] ?? '',
|
||||||
|
type: json['type'] ?? 'filter',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) {
|
||||||
|
return PlexFilterValue(
|
||||||
|
key: json['key'] ?? '',
|
||||||
|
title: json['title'] ?? '',
|
||||||
|
type: json['type'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'key': key, 'title': title, if (type != null) 'type': type};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> json) =>
|
||||||
|
_$PlexLibraryFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$PlexLibraryToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'plex_library.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> 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<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
class PlexMediaInfo {
|
||||||
|
final String videoUrl;
|
||||||
|
final List<PlexAudioTrack> audioTracks;
|
||||||
|
final List<PlexSubtitleTrack> subtitleTracks;
|
||||||
|
final List<PlexChapter> 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 = <String>[];
|
||||||
|
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 = <String>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) =>
|
||||||
|
_$PlexMetadataFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$PlexMetadataToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'plex_metadata.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> 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<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'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,
|
||||||
|
};
|
||||||
@@ -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<String, dynamic> json) =>
|
||||||
|
_$PlexServerInfoFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$PlexServerInfoToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'plex_server_info.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
PlexServerInfo _$PlexServerInfoFromJson(Map<String, dynamic> 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<String, dynamic> _$PlexServerInfoToJson(PlexServerInfo instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'name': instance.name,
|
||||||
|
'host': instance.host,
|
||||||
|
'port': instance.port,
|
||||||
|
'machineIdentifier': instance.machineIdentifier,
|
||||||
|
'version': instance.version,
|
||||||
|
'owned': instance.owned,
|
||||||
|
'https': instance.https,
|
||||||
|
};
|
||||||
@@ -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<String>? defaultAudioLanguages;
|
||||||
|
final String? defaultSubtitleLanguage;
|
||||||
|
final List<String>? defaultSubtitleLanguages;
|
||||||
|
final int autoSelectSubtitle;
|
||||||
|
final int defaultSubtitleAccessibility;
|
||||||
|
final int defaultSubtitleForced;
|
||||||
|
final int watchedIndicator;
|
||||||
|
final int mediaReviewsVisibility;
|
||||||
|
final List<String>? 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<String, dynamic> json) {
|
||||||
|
final profile = json['profile'] as Map<String, dynamic>? ?? 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<String>.from(profile['defaultAudioLanguages'] as List)
|
||||||
|
: null,
|
||||||
|
defaultSubtitleLanguage: profile['defaultSubtitleLanguage'] as String?,
|
||||||
|
defaultSubtitleLanguages: profile['defaultSubtitleLanguages'] != null
|
||||||
|
? List<String>.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<String>.from(profile['mediaReviewsLanguages'] as List)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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;
|
||||||
|
}
|
||||||
@@ -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<AuthScreen> createState() => _AuthScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthScreenState extends State<AuthScreen> {
|
||||||
|
bool _isAuthenticating = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
late PlexAuthService _authService;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeAuthService();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeAuthService() async {
|
||||||
|
_authService = await PlexAuthService.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable {
|
||||||
|
List<PlexMetadata> _onDeck = [];
|
||||||
|
List<PlexMetadata> _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<void> _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<void> _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<PlexServer>(
|
||||||
|
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<void> _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<void> _handleLogout() async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
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<String>(
|
||||||
|
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<bool>(
|
||||||
|
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<bool>(
|
||||||
|
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<PlexMetadata> 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LibrariesScreen> createState() => _LibrariesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LibrariesScreenState extends State<LibrariesScreen> with Refreshable {
|
||||||
|
List<PlexLibrary> _libraries = [];
|
||||||
|
List<PlexMetadata> _items = [];
|
||||||
|
List<PlexFilter> _filters = [];
|
||||||
|
bool _isLoadingLibraries = true;
|
||||||
|
bool _isLoadingItems = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
int _selectedLibraryIndex = 0;
|
||||||
|
Map<String, String> _selectedFilters = {};
|
||||||
|
bool _isInitialLoad = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadLibraries();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _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<void> _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<PlexFilter> filters;
|
||||||
|
final Map<String, String> selectedFilters;
|
||||||
|
final PlexClient client;
|
||||||
|
final Function(Map<String, String>) 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<PlexFilterValue> _filterValues = [];
|
||||||
|
bool _isLoadingValues = false;
|
||||||
|
final Map<String, String> _tempSelectedFilters = {};
|
||||||
|
final Map<String, String> _filterDisplayNames = {}; // Cache for display names
|
||||||
|
late List<PlexFilter> _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<void> _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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MainScreen> createState() => _MainScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||||
|
int _currentIndex = 0;
|
||||||
|
|
||||||
|
late final List<Widget> _screens;
|
||||||
|
final GlobalKey<State<DiscoverScreen>> _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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MediaDetailScreen> createState() => _MediaDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||||
|
List<PlexMetadata> _seasons = [];
|
||||||
|
bool _isLoadingSeasons = false;
|
||||||
|
PlexMetadata? _fullMetadata;
|
||||||
|
PlexMetadata? _onDeckEpisode;
|
||||||
|
bool _isLoadingMetadata = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadFullMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SearchScreen> createState() => _SearchScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchScreenState extends State<SearchScreen> with Refreshable {
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
List<PlexMetadata> _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<void> _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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SeasonDetailScreenState extends State<SeasonDetailScreen> {
|
||||||
|
List<PlexMetadata> _episodes = [];
|
||||||
|
bool _isLoadingEpisodes = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadEpisodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ServerSelectionScreen> createState() => _ServerSelectionScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||||
|
List<PlexServer>? _servers;
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||||
|
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<void> _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<void> _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<AudioTrack> 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<AudioTrack> 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<SubtitleTrack> 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<SubtitleTrack> 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<void> 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<void> _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<void> _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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> _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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PlexAuthService> 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<bool> 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<Map<String, dynamic>> 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String?> 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<String, dynamic>;
|
||||||
|
return data['authToken'] as String?;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the PIN until it's claimed or timeout
|
||||||
|
Future<String?> 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<List<PlexServer>> 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<dynamic> resources = response.data as List<dynamic>;
|
||||||
|
|
||||||
|
// Filter for server resources and map to PlexServer objects
|
||||||
|
return resources
|
||||||
|
.where((r) => r['provides'] == 'server')
|
||||||
|
.map((r) => PlexServer.fromJson(r as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user information
|
||||||
|
Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user profile with preferences (audio/subtitle settings)
|
||||||
|
Future<PlexUserProfile> 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<String, dynamic>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a Plex Media Server
|
||||||
|
class PlexServer {
|
||||||
|
final String name;
|
||||||
|
final String clientIdentifier;
|
||||||
|
final String accessToken;
|
||||||
|
final List<PlexConnection> 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<String, dynamic> json) {
|
||||||
|
final List<dynamic> connectionsJson = json['connections'] as List<dynamic>;
|
||||||
|
final connections = connectionsJson
|
||||||
|
.map((c) => PlexConnection.fromJson(c as Map<String, dynamic>))
|
||||||
|
.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<String, dynamic> 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<PlexConnection?> 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<PlexConnection>()
|
||||||
|
.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<String, dynamic> 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<String, dynamic> 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<StorageService> getInstance() async {
|
||||||
|
if (_instance == null) {
|
||||||
|
_instance = StorageService._();
|
||||||
|
await _instance!._init();
|
||||||
|
}
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
_prefs = await SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server URL
|
||||||
|
Future<void> saveServerUrl(String url) async {
|
||||||
|
await _prefs.setString(_keyServerUrl, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getServerUrl() {
|
||||||
|
return _prefs.getString(_keyServerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server Access Token
|
||||||
|
Future<void> saveToken(String token) async {
|
||||||
|
await _prefs.setString(_keyToken, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getToken() {
|
||||||
|
return _prefs.getString(_keyToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias for server access token for clarity
|
||||||
|
Future<void> saveServerAccessToken(String token) async {
|
||||||
|
await saveToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getServerAccessToken() {
|
||||||
|
return getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plex.tv Token (for API access)
|
||||||
|
Future<void> savePlexToken(String token) async {
|
||||||
|
await _prefs.setString(_keyPlexToken, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getPlexToken() {
|
||||||
|
return _prefs.getString(_keyPlexToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server Data (full PlexServer object as JSON)
|
||||||
|
Future<void> saveServerData(Map<String, dynamic> serverJson) async {
|
||||||
|
final jsonString = json.encode(serverJson);
|
||||||
|
await _prefs.setString(_keyServerData, jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? getServerData() {
|
||||||
|
final jsonString = _prefs.getString(_keyServerData);
|
||||||
|
if (jsonString == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return json.decode(jsonString) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Identifier
|
||||||
|
Future<void> saveClientIdentifier(String clientId) async {
|
||||||
|
await _prefs.setString(_keyClientId, clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getClientIdentifier() {
|
||||||
|
return _prefs.getString(_keyClientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save all credentials at once
|
||||||
|
Future<void> 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<void> 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<String, String?> getCredentials() {
|
||||||
|
return {
|
||||||
|
'serverUrl': getServerUrl(),
|
||||||
|
'token': getToken(),
|
||||||
|
'clientIdentifier': getClientIdentifier(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected Library Index
|
||||||
|
Future<void> saveSelectedLibraryIndex(int index) async {
|
||||||
|
await _prefs.setInt(_keySelectedLibraryIndex, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? getSelectedLibraryIndex() {
|
||||||
|
return _prefs.getInt(_keySelectedLibraryIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library Filters (stored as JSON string)
|
||||||
|
Future<void> saveLibraryFilters(Map<String, String> filters) async {
|
||||||
|
final jsonString = json.encode(filters);
|
||||||
|
await _prefs.setString(_keyLibraryFilters, jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> getLibraryFilters() {
|
||||||
|
final jsonString = _prefs.getString(_keyLibraryFilters);
|
||||||
|
if (jsonString == null) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = json.decode(jsonString) as Map<String, dynamic>;
|
||||||
|
return decoded.map((key, value) => MapEntry(key, value.toString()));
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear library preferences
|
||||||
|
Future<void> clearLibraryPreferences() async {
|
||||||
|
await Future.wait([
|
||||||
|
_prefs.remove(_keySelectedLibraryIndex),
|
||||||
|
_prefs.remove(_keyLibraryFilters),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Profile (stored as JSON string)
|
||||||
|
Future<void> saveUserProfile(Map<String, dynamic> profileJson) async {
|
||||||
|
final jsonString = json.encode(profileJson);
|
||||||
|
await _prefs.setString(_keyUserProfile, jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? getUserProfile() {
|
||||||
|
final jsonString = _prefs.getString(_keyUserProfile);
|
||||||
|
if (jsonString == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return json.decode(jsonString) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
@@ -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<String, dynamic>? _codes;
|
||||||
|
|
||||||
|
/// Load the language codes from JSON
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_codes != null) return;
|
||||||
|
|
||||||
|
final jsonString = await rootBundle.loadString(
|
||||||
|
'lib/data/iso_639_codes.json',
|
||||||
|
);
|
||||||
|
_codes = json.decode(jsonString) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String> getVariations(String languageCode) {
|
||||||
|
if (_codes == null) {
|
||||||
|
throw StateError(
|
||||||
|
'LanguageCodes not initialized. Call initialize() first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalized = languageCode.toLowerCase().trim();
|
||||||
|
final variations = <String>{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<String, dynamic>;
|
||||||
|
|
||||||
|
// 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<String, dynamic>;
|
||||||
|
|
||||||
|
// 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<String, dynamic>;
|
||||||
|
return entry['name'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for 3-letter code
|
||||||
|
for (var entry in _codes!.values) {
|
||||||
|
final entryMap = entry as Map<String, dynamic>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Widget>? 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<Widget>? 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<Widget>? 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<Widget>? 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MediaCard> createState() => _MediaCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaCardState extends State<MediaCard> {
|
||||||
|
void _handleTap(BuildContext context) async {
|
||||||
|
final itemType = widget.item.type.toLowerCase();
|
||||||
|
|
||||||
|
// For episodes, start playback directly
|
||||||
|
if (itemType == 'episode') {
|
||||||
|
final result = await Navigator.push<bool>(
|
||||||
|
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<bool>(
|
||||||
|
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<Color>(
|
||||||
|
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<Color>(
|
||||||
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MediaContextMenu> createState() => _MediaContextMenuState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
flutter/ephemeral
|
||||||
@@ -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 "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||||
|
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>: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()
|
||||||
@@ -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}
|
||||||
|
)
|
||||||