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}
|
||||
)
|
||||