commit a6effe208bbc4bfec04baf29809bdd7c57c199c0 Author: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat Oct 18 00:05:20 2025 +0200 squash diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..a11ff7ef --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,208 @@ +name: Build Flutter App + +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + build-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install dependencies + run: flutter pub get + + - name: Build APK + run: flutter build apk --release + + - name: Rename APK + run: | + cp build/app/outputs/flutter-apk/app-release.apk plezy-android.apk + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: plezy-android.apk + + build-ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install dependencies + run: flutter pub get + + - name: Build iOS (no codesign) + run: flutter build ios --release --no-codesign + + - name: Create IPA + run: | + mkdir -p Payload + cp -r build/ios/iphoneos/Runner.app Payload/ + zip -r plezy-ios.ipa Payload + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: ios-ipa + path: plezy-ios.ipa + + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install dependencies + run: flutter pub get + + - name: Build macOS + run: flutter build macos --release + + - name: Install create-dmg + run: brew install create-dmg + + - name: Create DMG + run: | + cd build/macos/Build/Products/Release + + # Create a temporary directory for DMG contents + mkdir -p dmg_temp + cp -R plezy.app dmg_temp/ + + # Create DMG with create-dmg + create-dmg \ + --volname "Plezy" \ + --volicon "plezy.app/Contents/Resources/AppIcon.icns" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "plezy.app" 200 190 \ + --hide-extension "plezy.app" \ + --app-drop-link 600 185 \ + --hdiutil-quiet \ + "$GITHUB_WORKSPACE/plezy-macos.dmg" \ + "dmg_temp/" + + - name: Upload macOS DMG + uses: actions/upload-artifact@v4 + with: + name: macos-dmg + path: plezy-macos.dmg + + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install dependencies + run: flutter pub get + + - name: Build Windows + run: flutter build windows --release + + - name: Build Windows Installer + run: .\windows\build-installer.ps1 + + - name: Upload Portable Archive + uses: actions/upload-artifact@v4 + with: + name: windows-portable + path: plezy-windows-portable.zip + + - name: Upload Installer + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: plezy-windows-installer.exe + + build-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev libasound2-dev libmpv-dev mpv + + - name: Install dependencies + run: flutter pub get + + - name: Build Linux + run: flutter build linux --release + + - name: Create Archive + run: | + cd build/linux/x64/release/bundle + tar -czf $GITHUB_WORKSPACE/plezy-linux.tar.gz * + + - name: Upload Linux App + uses: actions/upload-artifact@v4 + with: + name: linux-app + path: plezy-linux.tar.gz + + create-release: + needs: [build-android, build-ios, build-macos, build-windows, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Display structure of downloaded files + run: ls -R artifacts + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + artifacts/android-apk/plezy-android.apk + artifacts/ios-ipa/plezy-ios.ipa + artifacts/macos-dmg/plezy-macos.dmg + artifacts/windows-portable/plezy-windows-portable.zip + artifacts/windows-installer/plezy-windows-installer.exe + artifacts/linux-app/plezy-linux.tar.gz + draft: true + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..79c113f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 00000000..3f600b8d --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "077b4a4ce10a07b82caa6897f0c626f9c0a3ac90" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: android + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: ios + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: linux + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: macos + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: web + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + - platform: windows + create_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + base_revision: 077b4a4ce10a07b82caa6897f0c626f9c0a3ac90 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 00000000..8c6789db --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Plezy + +Plezy is a modern Plex media client that provides a seamless streaming experience across desktop and mobile platforms. Built with Flutter, it offers native performance and a clean, intuitive interface for browsing and playing your Plex media library. + +## Download + +### Mobile +Coming soon + +### Desktop +- [Windows (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-x64.zip) +- [macOS (Universal)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg) +- [Linux (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) + +> Download the latest release from the [Releases page](https://github.com/edde746/plezy/releases) + +## Features + +### 🔐 Authentication & Server Management +- OAuth PIN-based authentication +- Automatic server discovery with smart connection selection +- Persistent sessions with auto-login + +### 📚 Media Browsing +- Browse libraries with rich metadata +- Discover featured content +- Advanced search across all media +- Season and episode navigation + +### 🎬 Video Playback (MediaKit/mpv) +- Wide codec support including HEVC, AV1, VP9, and more +- Advanced subtitle rendering with full ASS/SSA support +- Audio and subtitle track selection with user profile preferences +- Playback progress sync and resume functionality +- Auto-play next episode + +### 🎨 User Experience +- Material Design 3 with dark theme +- Custom controls and context menus +- Desktop window management with custom macOS titlebar +- Fullscreen support with adaptive orientation + +## Platform Support + +- ✅ macOS +- ✅ Windows +- ✅ Linux +- ✅ Android +- ✅ iOS + +## Prerequisites + +- Flutter SDK 3.8.1 or higher +- A Plex account +- Access to a Plex Media Server (local or remote) + +## Installation + +1. Clone the repository: +```bash +git clone https://github.com/edde746/plezy.git +cd plezy +``` + +2. Install dependencies: +```bash +flutter pub get +``` + +3. Generate required code: +```bash +dart run build_runner build +``` + +4. Run the application: +```bash +flutter run +``` + +## Configuration + +On first launch, Plezy will guide you through: +1. Authenticating with your Plex account +2. Selecting your Plex Media Server +3. Testing connections to find the optimal one + +Your credentials and preferences are securely stored locally for automatic sign-in on subsequent launches. + +## Development + +### Code Generation + +The project uses code generation for JSON serialization. After modifying model classes, run: + +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +## Building for Production + +### Android +```bash +flutter build apk --release +# or +flutter build appbundle --release +``` + +### Desktop +```bash +flutter build macos --release +flutter build windows --release +flutter build linux --release +``` + +## Acknowledgments + +- Built with [Flutter](https://flutter.dev) +- Media playback powered by [MediaKit](https://github.com/media-kit/media-kit) +- Designed for [Plex Media Server](https://www.plex.tv) diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..f9b30346 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..5b48e762 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.edde746.plezy" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + applicationId = "com.edde746.plezy" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..60b14e5a --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt new file mode 100644 index 00000000..c545cdce --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt @@ -0,0 +1,5 @@ +package com.edde746.plezy + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..2727eef3 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..31363756 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..7d04f18d Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..4ccdb0b7 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..cee25601 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..5f349f7f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..cb0ba33f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..ac752da5 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..ec876f0c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..b6335695 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..14329ac5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..d8acb917 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..ab983282 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #ffffff + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..60fb10ee --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..89176ef4 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..f018a618 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ac3b4792 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..ab39a10a --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/plezy.png b/assets/plezy.png new file mode 100644 index 00000000..1d8925a4 Binary files /dev/null and b/assets/plezy.png differ diff --git a/assets/plezy.svg b/assets/plezy.svg new file mode 100644 index 00000000..d34219dd --- /dev/null +++ b/assets/plezy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/plezy_android_foreground.png b/assets/plezy_android_foreground.png new file mode 100644 index 00000000..68bda7f9 Binary files /dev/null and b/assets/plezy_android_foreground.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..1dc6cf76 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..ec97fc6f --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..c4855bfe --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 00000000..620e46eb --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 00000000..63a235f0 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,73 @@ +PODS: + - Flutter (1.0.0) + - media_kit_libs_ios_video (1.0.4): + - Flutter + - media_kit_video (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + - volume_controller (0.0.1): + - Flutter + - wakelock_plus (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) + - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - volume_controller (from `.symlinks/plugins/volume_controller/ios`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + media_kit_libs_ios_video: + :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" + media_kit_video: + :path: ".symlinks/plugins/media_kit_video/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + volume_controller: + :path: ".symlinks/plugins/volume_controller/ios" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 + media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..7c5f2ceb --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,733 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C12D873DE8572579684A82C /* Pods_Runner.framework */; }; + 6A48DFB02EA70C7100C1F7CD /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + F37BB572E0F0606E20596E43 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1E1EC7646F3085BF9127AB2A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 2C12D873DE8572579684A82C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5B81FA7048BFA976F49D555E /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 83B9EB35C25EEF40090F6A88 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 87ADF6219C65A13967DFF03C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A8738BEC00467F05948734DD /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D239CED757CED630C2B0A161 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 582EBCC1C90BEBEC365D972D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F37BB572E0F0606E20596E43 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 67716449E04A72AA71A90156 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2C12D873DE8572579684A82C /* Pods_Runner.framework */, + 3469ED9D4F3E58F8C3AEB7DC /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 875C8AEFBBA3759A1849D674 /* Pods */ = { + isa = PBXGroup; + children = ( + A8738BEC00467F05948734DD /* Pods-Runner.debug.xcconfig */, + 83B9EB35C25EEF40090F6A88 /* Pods-Runner.release.xcconfig */, + 1E1EC7646F3085BF9127AB2A /* Pods-Runner.profile.xcconfig */, + 5B81FA7048BFA976F49D555E /* Pods-RunnerTests.debug.xcconfig */, + D239CED757CED630C2B0A161 /* Pods-RunnerTests.release.xcconfig */, + 87ADF6219C65A13967DFF03C /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 875C8AEFBBA3759A1849D674 /* Pods */, + 67716449E04A72AA71A90156 /* Frameworks */, + 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 684A4A968F313A7FDAEBF3B8 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 582EBCC1C90BEBEC365D972D /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 01D58DD9E289D97FD8DBE66E /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 2E10BAE564A4AFFCFF5B4EF0 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 6A48DFB02EA70C7100C1F7CD /* plezy.icon in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 01D58DD9E289D97FD8DBE66E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 2E10BAE564A4AFFCFF5B4EF0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 684A4A968F313A7FDAEBF3B8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5B81FA7048BFA976F49D555E /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D239CED757CED630C2B0A161 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 87ADF6219C65A13967DFF03C /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G88U5B5783; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e3773d42 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..62666446 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 00000000..0fc3e407 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,60 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Plezy + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Plezy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _plexmediasvr._tcp + + NSLocalNetworkUsageDescription + This app needs to connect to your Plex Media Server on your local network. + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/ios/plezy.icon/Assets/plezy-cropped.svg b/ios/plezy.icon/Assets/plezy-cropped.svg new file mode 100644 index 00000000..d34219dd --- /dev/null +++ b/ios/plezy.icon/Assets/plezy-cropped.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ios/plezy.icon/icon.json b/ios/plezy.icon/icon.json new file mode 100644 index 00000000..3af70ecd --- /dev/null +++ b/ios/plezy.icon/icon.json @@ -0,0 +1,55 @@ +{ + "fill" : { + "solid" : "gray:0.94630,1.00000" + }, + "groups" : [ + { + "blur-material" : null, + "layers" : [ + { + "blend-mode-specializations" : [ + { + "appearance" : "dark", + "value" : "normal" + } + ], + "fill-specializations" : [ + { + "value" : "none" + }, + { + "appearance" : "dark", + "value" : "none" + } + ], + "glass" : true, + "hidden" : false, + "image-name" : "plezy-cropped.svg", + "name" : "plezy-cropped", + "position" : { + "scale" : 2, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/lib/auth/plex_auth.dart b/lib/auth/plex_auth.dart new file mode 100644 index 00000000..b8cfb598 --- /dev/null +++ b/lib/auth/plex_auth.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class PlexAuth { + static const String authUrl = 'https://plex.tv/api/v2'; + static const String clientsUrl = 'https://clients.plex.tv/api/v2'; + + final String clientIdentifier; + final String product; + + PlexAuth({ + required this.clientIdentifier, + this.product = 'Plex Flutter Client', + }); + + Map get _headers => { + 'Accept': 'application/json', + 'X-Plex-Client-Identifier': clientIdentifier, + 'X-Plex-Product': product, + }; + + /// Generate a PIN for authentication + Future> generatePin({bool strong = true}) async { + final response = await http.post( + Uri.parse('$authUrl/pins?strong=$strong'), + headers: _headers, + ); + + if (response.statusCode == 201) { + return json.decode(response.body); + } else { + throw Exception('Failed to generate PIN: ${response.body}'); + } + } + + /// Check PIN status + Future> checkPin(int pinId) async { + final response = await http.get( + Uri.parse('$authUrl/pins/$pinId'), + headers: _headers, + ); + + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + throw Exception('Failed to check PIN: ${response.body}'); + } + } + + /// Get auth app URL for user to authenticate + String getAuthAppUrl(String code, {String? forwardUrl}) { + final params = { + 'clientID': clientIdentifier, + 'code': code, + 'context[device][product]': product, + if (forwardUrl != null) 'forwardUrl': forwardUrl, + }; + + final queryString = params.entries + .map( + (e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', + ) + .join('&'); + + return 'https://app.plex.tv/auth#?$queryString'; + } + + /// Verify token validity + Future verifyToken(String token) async { + try { + final response = await http.get( + Uri.parse('$authUrl/user'), + headers: {..._headers, 'X-Plex-Token': token}, + ); + return response.statusCode == 200; + } catch (e) { + return false; + } + } + + /// Get user info + Future> getUserInfo(String token) async { + final response = await http.get( + Uri.parse('$authUrl/user'), + headers: {..._headers, 'X-Plex-Token': token}, + ); + + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + throw Exception('Failed to get user info: ${response.body}'); + } + } + + /// Get available resources (servers) + Future> getResources(String token) async { + final response = await http.get( + Uri.parse( + '$clientsUrl/resources?includeHttps=1&includeRelay=1&includeIPv6=1', + ), + headers: {..._headers, 'X-Plex-Token': token}, + ); + + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + throw Exception('Failed to get resources: ${response.body}'); + } + } +} diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart new file mode 100644 index 00000000..0594a426 --- /dev/null +++ b/lib/client/plex_client.dart @@ -0,0 +1,663 @@ +import 'package:dio/dio.dart'; +import '../config/plex_config.dart'; +import '../models/plex_library.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_media_info.dart'; +import '../models/plex_filter.dart'; +import '../utils/app_logger.dart'; + +class PlexClient { + final PlexConfig config; + late final Dio _dio; + + PlexClient(this.config) { + _dio = Dio( + BaseOptions( + baseUrl: config.baseUrl, + headers: config.headers, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + validateStatus: (status) => status != null && status < 500, + ), + ); + + // Add interceptor for logging (optional, can be disabled in production) + _dio.interceptors.add( + LogInterceptor( + requestBody: false, + responseBody: false, + error: true, + requestHeader: false, + responseHeader: false, + ), + ); + } + + /// Test connection to server + Future testConnection() async { + try { + final response = await _dio.get('/'); + return response.statusCode == 200 || response.statusCode == 401; + } catch (e) { + return false; + } + } + + /// Test connection to a specific URL with token + static Future testConnectionUrl( + String baseUrl, + String token, { + Duration timeout = const Duration(seconds: 5), + }) async { + try { + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: timeout, + receiveTimeout: timeout, + validateStatus: (status) => status != null && status < 500, + ), + ); + + final response = await dio.get( + '/', + options: Options(headers: {'X-Plex-Token': token}), + ); + + return response.statusCode == 200 || response.statusCode == 401; + } catch (e) { + return false; + } + } + + /// Get server identity + Future> getServerIdentity() async { + final response = await _dio.get('/identity'); + return response.data; + } + + /// Get library sections + Future> getLibraries() async { + final response = await _dio.get('/library/sections'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Directory'] != null) { + return (container['Directory'] as List) + .map((json) => PlexLibrary.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get library content by section ID + Future> getLibraryContent( + String sectionId, { + int? start, + int? size, + Map? filters, + }) async { + final queryParams = {}; + if (start != null) queryParams['X-Plex-Container-Start'] = start; + if (size != null) queryParams['X-Plex-Container-Size'] = size; + + // Add filter parameters + if (filters != null) { + queryParams.addAll(filters); + } + + final response = await _dio.get( + '/library/sections/$sectionId/all', + queryParameters: queryParams, + ); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get metadata by rating key + Future getMetadata(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + return PlexMetadata.fromJson(container['Metadata'][0]); + } + } + + return null; + } + + /// Get metadata by rating key with images (includes clearLogo and OnDeck) + Future> getMetadataWithImagesAndOnDeck( + String ratingKey, + ) async { + final response = await _dio.get( + '/library/metadata/$ratingKey', + queryParameters: {'includeOnDeck': 1}, + ); + + PlexMetadata? metadata; + PlexMetadata? onDeckEpisode; + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + + // Get main metadata + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + final metadataJson = container['Metadata'][0]; + metadata = PlexMetadata.fromJsonWithImages(metadataJson); + + // Check if OnDeck is nested inside Metadata + if (metadataJson.containsKey('OnDeck') && + metadataJson['OnDeck'] != null) { + final onDeckData = metadataJson['OnDeck']; + + // OnDeck can be either a Map with 'Metadata' key or direct metadata + if (onDeckData is Map && onDeckData.containsKey('Metadata')) { + final onDeckMetadata = onDeckData['Metadata']; + if (onDeckMetadata != null) { + onDeckEpisode = PlexMetadata.fromJson(onDeckMetadata); + } + } + } + } + } + + return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode}; + } + + /// Get metadata by rating key with images (includes clearLogo) + Future getMetadataWithImages(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + return PlexMetadata.fromJsonWithImages(container['Metadata'][0]); + } + } + + return null; + } + + /// Search across all libraries using the hub search endpoint + /// Only returns movies and shows, filtering out seasons and episodes + Future> search(String query, {int limit = 10}) async { + final response = await _dio.get( + '/hubs/search', + queryParameters: { + 'query': query, + 'limit': limit, + 'includeCollections': 1, + }, + ); + + final results = []; + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Hub'] != null) { + // Each hub contains results of a specific type (movies, shows, etc.) + for (final hub in container['Hub'] as List) { + final hubType = hub['type'] as String?; + + // Only include movie and show hubs + if (hubType != 'movie' && hubType != 'show') { + continue; + } + + // Hubs can contain either Metadata (for movies) or Directory (for shows) + if (hub['Metadata'] != null) { + for (final json in hub['Metadata'] as List) { + try { + results.add(PlexMetadata.fromJson(json)); + } catch (e) { + // Skip items that fail to parse + appLogger.w('Failed to parse search result', error: e); + appLogger.d('Problematic JSON: $json'); + } + } + } + if (hub['Directory'] != null) { + for (final json in hub['Directory'] as List) { + try { + results.add(PlexMetadata.fromJson(json)); + } catch (e) { + // Skip items that fail to parse + appLogger.w('Failed to parse search result', error: e); + appLogger.d('Problematic JSON: $json'); + } + } + } + } + } + } + + return results; + } + + /// Get recently added media + Future> getRecentlyAdded({int limit = 50}) async { + final response = await _dio.get( + '/library/recentlyAdded', + queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, + ); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get on deck items (continue watching) + Future> getOnDeck() async { + final response = await _dio.get('/library/onDeck'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJsonWithImages(json)) + .toList(); + } + } + + return []; + } + + /// Get children of a metadata item (e.g., seasons for a show, episodes for a season) + Future> getChildren(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey/children'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return (container['Metadata'] as List) + .map((json) => PlexMetadata.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get thumbnail URL + String getThumbnailUrl(String? thumbPath) { + if (thumbPath == null || thumbPath.isEmpty) return ''; + + // Remove leading slash if present + final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath; + + return '${config.baseUrl}/$path?X-Plex-Token=${config.token}'; + } + + /// Get video URL for direct playback + Future getVideoUrl(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + final metadata = container['Metadata'][0]; + + // Get the first Media item and its Part + if (metadata['Media'] != null && + (metadata['Media'] as List).isNotEmpty) { + final media = metadata['Media'][0]; + if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { + final part = media['Part'][0]; + final partKey = part['key'] as String?; + + if (partKey != null) { + // Return direct play URL + return '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; + } + } + } + } + } + + return null; + } + + /// Get chapters for a media item + Future> getChapters(String ratingKey) async { + final response = await _dio.get( + '/library/metadata/$ratingKey', + queryParameters: {'includeChapters': 1}, + ); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + final metadata = container['Metadata'][0]; + + if (metadata['Chapter'] != null) { + final chapterList = metadata['Chapter'] as List; + return chapterList.map((chapter) { + return PlexChapter( + id: chapter['id'] as int, + index: chapter['index'] as int?, + startTimeOffset: chapter['startTimeOffset'] as int?, + endTimeOffset: chapter['endTimeOffset'] as int?, + title: chapter['tag'] as String?, + thumb: chapter['thumb'] as String?, + ); + }).toList(); + } + } + } + + return []; + } + + /// Get detailed media info including chapters and tracks + Future getMediaInfo(String ratingKey) async { + final response = await _dio.get('/library/metadata/$ratingKey'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + final metadata = container['Metadata'][0]; + + // Get the first Media item and its Part + if (metadata['Media'] != null && + (metadata['Media'] as List).isNotEmpty) { + final media = metadata['Media'][0]; + if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { + final part = media['Part'][0]; + final partKey = part['key'] as String?; + + if (partKey != null) { + // Parse streams (audio and subtitle tracks) + final streams = part['Stream'] as List? ?? []; + final audioTracks = []; + final subtitleTracks = []; + + for (var stream in streams) { + final streamType = stream['streamType'] as int?; + + if (streamType == 2) { + // Audio track + audioTracks.add( + PlexAudioTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + channels: stream['channels'] as int?, + selected: stream['selected'] == 1, + ), + ); + } else if (streamType == 3) { + // Subtitle track + subtitleTracks.add( + PlexSubtitleTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + selected: stream['selected'] == 1, + forced: stream['forced'] == 1, + key: stream['key'] as String?, + ), + ); + } + } + + // Parse chapters + final chapters = []; + if (metadata['Chapter'] != null) { + final chapterList = metadata['Chapter'] as List; + for (var chapter in chapterList) { + chapters.add( + PlexChapter( + id: chapter['id'] as int, + index: chapter['index'] as int?, + startTimeOffset: chapter['startTimeOffset'] as int?, + endTimeOffset: chapter['endTimeOffset'] as int?, + title: chapter['title'] as String?, + thumb: chapter['thumb'] as String?, + ), + ); + } + } + + return PlexMediaInfo( + videoUrl: + '${config.baseUrl}$partKey?X-Plex-Token=${config.token}', + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: chapters, + ); + } + } + } + } + } + + return null; + } + + /// Mark media as watched + Future markAsWatched(String ratingKey) async { + await _dio.get( + '/:/scrobble', + queryParameters: { + 'key': ratingKey, + 'identifier': 'com.plexapp.plugins.library', + }, + ); + } + + /// Mark media as unwatched + Future markAsUnwatched(String ratingKey) async { + await _dio.get( + '/:/unscrobble', + queryParameters: { + 'key': ratingKey, + 'identifier': 'com.plexapp.plugins.library', + }, + ); + } + + /// Update playback progress + Future updateProgress( + String ratingKey, { + required int time, + required String state, // 'playing', 'paused', 'stopped', 'buffering' + int? duration, + }) async { + await _dio.post( + '/:/timeline', + queryParameters: { + 'ratingKey': ratingKey, + 'key': '/library/metadata/$ratingKey', + 'time': time, + 'state': state, + if (duration != null) 'duration': duration, + }, + ); + } + + /// Get server preferences + Future> getServerPreferences() async { + final response = await _dio.get('/:/prefs'); + return response.data; + } + + /// Get sessions (currently playing) + Future> getSessions() async { + final response = await _dio.get('/status/sessions'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Metadata'] != null) { + return container['Metadata'] as List; + } + } + + return []; + } + + /// Get available filters for a library section + Future> getLibraryFilters(String sectionId) async { + final response = await _dio.get('/library/sections/$sectionId/filters'); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Directory'] != null) { + return (container['Directory'] as List) + .map((json) => PlexFilter.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get filter values (e.g., list of genres, years, etc.) + Future> getFilterValues(String filterKey) async { + final response = await _dio.get(filterKey); + + if (response.data is Map && response.data.containsKey('MediaContainer')) { + final container = response.data['MediaContainer']; + if (container['Directory'] != null) { + return (container['Directory'] as List) + .map((json) => PlexFilterValue.fromJson(json)) + .toList(); + } + } + + return []; + } + + /// Get next episode for a TV show episode + Future getNextEpisode(PlexMetadata currentEpisode) async { + if (currentEpisode.type.toLowerCase() != 'episode') { + return null; + } + + final parentKey = currentEpisode.parentRatingKey; + final grandparentKey = currentEpisode.grandparentRatingKey; + + if (parentKey == null || grandparentKey == null) { + return null; + } + + try { + // Get all episodes in the current season + final episodes = await getChildren(parentKey); + + // Find the current episode index + final currentIndex = episodes.indexWhere( + (e) => e.ratingKey == currentEpisode.ratingKey, + ); + + if (currentIndex != -1 && currentIndex < episodes.length - 1) { + // Return next episode in the same season + return episodes[currentIndex + 1]; + } else if (currentIndex == episodes.length - 1) { + // Last episode of the season, try to get first episode of next season + final seasons = await getChildren(grandparentKey); + final currentSeasonIndex = seasons.indexWhere( + (s) => s.ratingKey == parentKey, + ); + + if (currentSeasonIndex != -1 && + currentSeasonIndex < seasons.length - 1) { + final nextSeason = seasons[currentSeasonIndex + 1]; + final nextSeasonEpisodes = await getChildren(nextSeason.ratingKey); + + if (nextSeasonEpisodes.isNotEmpty) { + return nextSeasonEpisodes.first; + } + } + } + } catch (e) { + // Silently handle errors + } + + return null; + } + + /// Get previous episode for a TV show episode + Future getPreviousEpisode(PlexMetadata currentEpisode) async { + if (currentEpisode.type.toLowerCase() != 'episode') { + return null; + } + + final parentKey = currentEpisode.parentRatingKey; + final grandparentKey = currentEpisode.grandparentRatingKey; + + if (parentKey == null || grandparentKey == null) { + return null; + } + + try { + // Get all episodes in the current season + final episodes = await getChildren(parentKey); + + // Find the current episode index + final currentIndex = episodes.indexWhere( + (e) => e.ratingKey == currentEpisode.ratingKey, + ); + + if (currentIndex > 0) { + // Return previous episode in the same season + return episodes[currentIndex - 1]; + } else if (currentIndex == 0) { + // First episode of the season, try to get last episode of previous season + final seasons = await getChildren(grandparentKey); + final currentSeasonIndex = seasons.indexWhere( + (s) => s.ratingKey == parentKey, + ); + + if (currentSeasonIndex > 0) { + final previousSeason = seasons[currentSeasonIndex - 1]; + final previousSeasonEpisodes = await getChildren( + previousSeason.ratingKey, + ); + + if (previousSeasonEpisodes.isNotEmpty) { + return previousSeasonEpisodes.last; + } + } + } + } catch (e) { + // Silently handle errors + } + + return null; + } +} diff --git a/lib/config/plex_config.dart b/lib/config/plex_config.dart new file mode 100644 index 00000000..91babd2e --- /dev/null +++ b/lib/config/plex_config.dart @@ -0,0 +1,60 @@ +class PlexConfig { + final String baseUrl; + final String? token; + final String clientIdentifier; + final String product; + final String version; + final String platform; + final String? device; + final bool acceptJson; + + PlexConfig({ + required this.baseUrl, + this.token, + required this.clientIdentifier, + this.product = 'Plezy', + this.version = '1.0.0', + this.platform = 'Flutter', + this.device, + this.acceptJson = true, + }); + + Map get headers { + final headers = { + 'X-Plex-Client-Identifier': clientIdentifier, + 'X-Plex-Product': product, + 'X-Plex-Version': version, + 'X-Plex-Platform': platform, + if (device != null) 'X-Plex-Device': device!, + if (acceptJson) 'Accept': 'application/json', + }; + + if (token != null) { + headers['X-Plex-Token'] = token!; + } + + return headers; + } + + PlexConfig copyWith({ + String? baseUrl, + String? token, + String? clientIdentifier, + String? product, + String? version, + String? platform, + String? device, + bool? acceptJson, + }) { + return PlexConfig( + baseUrl: baseUrl ?? this.baseUrl, + token: token ?? this.token, + clientIdentifier: clientIdentifier ?? this.clientIdentifier, + product: product ?? this.product, + version: version ?? this.version, + platform: platform ?? this.platform, + device: device ?? this.device, + acceptJson: acceptJson ?? this.acceptJson, + ); + } +} diff --git a/lib/data/iso_639_codes.json b/lib/data/iso_639_codes.json new file mode 100644 index 00000000..7a74142f --- /dev/null +++ b/lib/data/iso_639_codes.json @@ -0,0 +1 @@ +{"aa":{"639-1":"aa","639-2":"aar","family":"Afro-Asiatic","name":"Afar","nativeName":"Afaraf","wikiUrl":"https://en.wikipedia.org/wiki/Afar_language"},"ab":{"639-1":"ab","639-2":"abk","family":"Northwest Caucasian","name":"Abkhaz","nativeName":"аҧсуа бызшәа, аҧсшәа","wikiUrl":"https://en.wikipedia.org/wiki/Abkhaz_language"},"ae":{"639-1":"ae","639-2":"ave","family":"Indo-European","name":"Avestan","nativeName":"avesta","wikiUrl":"https://en.wikipedia.org/wiki/Avestan_language"},"af":{"639-1":"af","639-2":"afr","family":"Indo-European","name":"Afrikaans","nativeName":"Afrikaans","wikiUrl":"https://en.wikipedia.org/wiki/Afrikaans_language"},"ak":{"639-1":"ak","639-2":"aka","family":"Niger–Congo","name":"Akan","nativeName":"Akan","wikiUrl":"https://en.wikipedia.org/wiki/Akan_language"},"am":{"639-1":"am","639-2":"amh","family":"Afro-Asiatic","name":"Amharic","nativeName":"አማርኛ","wikiUrl":"https://en.wikipedia.org/wiki/Amharic_language"},"an":{"639-1":"an","639-2":"arg","family":"Indo-European","name":"Aragonese","nativeName":"aragonés","wikiUrl":"https://en.wikipedia.org/wiki/Aragonese_language"},"ar":{"639-1":"ar","639-2":"ara","family":"Afro-Asiatic","name":"Arabic","nativeName":"العربية","wikiUrl":"https://en.wikipedia.org/wiki/Arabic_language"},"as":{"639-1":"as","639-2":"asm","family":"Indo-European","name":"Assamese","nativeName":"অসমীয়া","wikiUrl":"https://en.wikipedia.org/wiki/Assamese_language"},"av":{"639-1":"av","639-2":"ava","family":"Northeast Caucasian","name":"Avaric","nativeName":"авар мацӀ, магӀарул мацӀ","wikiUrl":"https://en.wikipedia.org/wiki/Avar_language"},"ay":{"639-1":"ay","639-2":"aym","family":"Aymaran","name":"Aymara","nativeName":"aymar aru","wikiUrl":"https://en.wikipedia.org/wiki/Aymara_language"},"az":{"639-1":"az","639-2":"aze","family":"Turkic","name":"Azerbaijani","nativeName":"azərbaycan dili","wikiUrl":"https://en.wikipedia.org/wiki/Azerbaijani_language"},"ba":{"639-1":"ba","639-2":"bak","family":"Turkic","name":"Bashkir","nativeName":"башҡорт теле","wikiUrl":"https://en.wikipedia.org/wiki/Bashkir_language"},"be":{"639-1":"be","639-2":"bel","family":"Indo-European","name":"Belarusian","nativeName":"беларуская мова","wikiUrl":"https://en.wikipedia.org/wiki/Belarusian_language"},"bg":{"639-1":"bg","639-2":"bul","family":"Indo-European","name":"Bulgarian","nativeName":"български език","wikiUrl":"https://en.wikipedia.org/wiki/Bulgarian_language"},"bh":{"639-1":"bh","639-2":"bih","family":"Indo-European","name":"Bihari","nativeName":"भोजपुरी","wikiUrl":"https://en.wikipedia.org/wiki/Bihari_languages"},"bi":{"639-1":"bi","639-2":"bis","family":"Creole","name":"Bislama","nativeName":"Bislama","wikiUrl":"https://en.wikipedia.org/wiki/Bislama_language"},"bm":{"639-1":"bm","639-2":"bam","family":"Niger–Congo","name":"Bambara","nativeName":"bamanankan","wikiUrl":"https://en.wikipedia.org/wiki/Bambara_language"},"bn":{"639-1":"bn","639-2":"ben","family":"Indo-European","name":"Bengali, Bangla","nativeName":"বাংলা","wikiUrl":"https://en.wikipedia.org/wiki/Bengali_language"},"bo":{"639-1":"bo","639-2":"bod","639-2/B":"tib","family":"Sino-Tibetan","name":"Tibetan Standard, Tibetan, Central","nativeName":"བོད་ཡིག","wikiUrl":"https://en.wikipedia.org/wiki/Standard_Tibetan"},"br":{"639-1":"br","639-2":"bre","family":"Indo-European","name":"Breton","nativeName":"brezhoneg","wikiUrl":"https://en.wikipedia.org/wiki/Breton_language"},"bs":{"639-1":"bs","639-2":"bos","family":"Indo-European","name":"Bosnian","nativeName":"bosanski jezik","wikiUrl":"https://en.wikipedia.org/wiki/Bosnian_language"},"ca":{"639-1":"ca","639-2":"cat","family":"Indo-European","name":"Catalan","nativeName":"català","wikiUrl":"https://en.wikipedia.org/wiki/Catalan_language"},"ce":{"639-1":"ce","639-2":"che","family":"Northeast Caucasian","name":"Chechen","nativeName":"нохчийн мотт","wikiUrl":"https://en.wikipedia.org/wiki/Chechen_language"},"ch":{"639-1":"ch","639-2":"cha","family":"Austronesian","name":"Chamorro","nativeName":"Chamoru","wikiUrl":"https://en.wikipedia.org/wiki/Chamorro_language"},"co":{"639-1":"co","639-2":"cos","family":"Indo-European","name":"Corsican","nativeName":"corsu, lingua corsa","wikiUrl":"https://en.wikipedia.org/wiki/Corsican_language"},"cr":{"639-1":"cr","639-2":"cre","family":"Algonquian","name":"Cree","nativeName":"ᓀᐦᐃᔭᐍᐏᐣ","wikiUrl":"https://en.wikipedia.org/wiki/Cree_language"},"cs":{"639-1":"cs","639-2":"ces","639-2/B":"cze","family":"Indo-European","name":"Czech","nativeName":"čeština, český jazyk","wikiUrl":"https://en.wikipedia.org/wiki/Czech_language"},"cu":{"639-1":"cu","639-2":"chu","family":"Indo-European","name":"Old Church Slavonic, Church Slavonic, Old Bulgarian","nativeName":"ѩзыкъ словѣньскъ","wikiUrl":"https://en.wikipedia.org/wiki/Old_Church_Slavonic"},"cv":{"639-1":"cv","639-2":"chv","family":"Turkic","name":"Chuvash","nativeName":"чӑваш чӗлхи","wikiUrl":"https://en.wikipedia.org/wiki/Chuvash_language"},"cy":{"639-1":"cy","639-2":"cym","639-2/B":"wel","family":"Indo-European","name":"Welsh","nativeName":"Cymraeg","wikiUrl":"https://en.wikipedia.org/wiki/Welsh_language"},"da":{"639-1":"da","639-2":"dan","family":"Indo-European","name":"Danish","nativeName":"dansk","wikiUrl":"https://en.wikipedia.org/wiki/Danish_language"},"de":{"639-1":"de","639-2":"deu","639-2/B":"ger","family":"Indo-European","name":"German","nativeName":"Deutsch","wikiUrl":"https://en.wikipedia.org/wiki/German_language"},"dv":{"639-1":"dv","639-2":"div","family":"Indo-European","name":"Divehi, Dhivehi, Maldivian","nativeName":"ދިވެހި","wikiUrl":"https://en.wikipedia.org/wiki/Dhivehi_language"},"dz":{"639-1":"dz","639-2":"dzo","family":"Sino-Tibetan","name":"Dzongkha","nativeName":"རྫོང་ཁ","wikiUrl":"https://en.wikipedia.org/wiki/Dzongkha_language"},"ee":{"639-1":"ee","639-2":"ewe","family":"Niger–Congo","name":"Ewe","nativeName":"Eʋegbe","wikiUrl":"https://en.wikipedia.org/wiki/Ewe_language"},"el":{"639-1":"el","639-2":"ell","639-2/B":"gre","family":"Indo-European","name":"Greek (modern)","nativeName":"ελληνικά","wikiUrl":"https://en.wikipedia.org/wiki/Greek_language"},"en":{"639-1":"en","639-2":"eng","family":"Indo-European","name":"English","nativeName":"English","wikiUrl":"https://en.wikipedia.org/wiki/English_language"},"eo":{"639-1":"eo","639-2":"epo","family":"Constructed","name":"Esperanto","nativeName":"Esperanto","wikiUrl":"https://en.wikipedia.org/wiki/Esperanto"},"es":{"639-1":"es","639-2":"spa","family":"Indo-European","name":"Spanish","nativeName":"Español","wikiUrl":"https://en.wikipedia.org/wiki/Spanish_language"},"et":{"639-1":"et","639-2":"est","family":"Uralic","name":"Estonian","nativeName":"eesti, eesti keel","wikiUrl":"https://en.wikipedia.org/wiki/Estonian_language"},"eu":{"639-1":"eu","639-2":"eus","639-2/B":"baq","family":"Language isolate","name":"Basque","nativeName":"euskara, euskera","wikiUrl":"https://en.wikipedia.org/wiki/Basque_language"},"fa":{"639-1":"fa","639-2":"fas","639-2/B":"per","family":"Indo-European","name":"Persian (Farsi)","nativeName":"فارسی","wikiUrl":"https://en.wikipedia.org/wiki/Persian_language"},"ff":{"639-1":"ff","639-2":"ful","family":"Niger–Congo","name":"Fula, Fulah, Pulaar, Pular","nativeName":"Fulfulde, Pulaar, Pular","wikiUrl":"https://en.wikipedia.org/wiki/Fula_language"},"fi":{"639-1":"fi","639-2":"fin","family":"Uralic","name":"Finnish","nativeName":"suomi, suomen kieli","wikiUrl":"https://en.wikipedia.org/wiki/Finnish_language"},"fj":{"639-1":"fj","639-2":"fij","family":"Austronesian","name":"Fijian","nativeName":"vosa Vakaviti","wikiUrl":"https://en.wikipedia.org/wiki/Fijian_language"},"fo":{"639-1":"fo","639-2":"fao","family":"Indo-European","name":"Faroese","nativeName":"føroyskt","wikiUrl":"https://en.wikipedia.org/wiki/Faroese_language"},"fr":{"639-1":"fr","639-2":"fra","639-2/B":"fre","family":"Indo-European","name":"French","nativeName":"français, langue française","wikiUrl":"https://en.wikipedia.org/wiki/French_language"},"fy":{"639-1":"fy","639-2":"fry","family":"Indo-European","name":"Western Frisian","nativeName":"Frysk","wikiUrl":"https://en.wikipedia.org/wiki/West_Frisian_language"},"ga":{"639-1":"ga","639-2":"gle","family":"Indo-European","name":"Irish","nativeName":"Gaeilge","wikiUrl":"https://en.wikipedia.org/wiki/Irish_language"},"gd":{"639-1":"gd","639-2":"gla","family":"Indo-European","name":"Scottish Gaelic, Gaelic","nativeName":"Gàidhlig","wikiUrl":"https://en.wikipedia.org/wiki/Scottish_Gaelic_language"},"gl":{"639-1":"gl","639-2":"glg","family":"Indo-European","name":"Galician","nativeName":"galego","wikiUrl":"https://en.wikipedia.org/wiki/Galician_language"},"gn":{"639-1":"gn","639-2":"grn","family":"Tupian","name":"Guaraní","nativeName":"Avañe'ẽ","wikiUrl":"https://en.wikipedia.org/wiki/Guaran%C3%AD_language"},"gu":{"639-1":"gu","639-2":"guj","family":"Indo-European","name":"Gujarati","nativeName":"ગુજરાતી","wikiUrl":"https://en.wikipedia.org/wiki/Gujarati_language"},"gv":{"639-1":"gv","639-2":"glv","family":"Indo-European","name":"Manx","nativeName":"Gaelg, Gailck","wikiUrl":"https://en.wikipedia.org/wiki/Manx_language"},"ha":{"639-1":"ha","639-2":"hau","family":"Afro-Asiatic","name":"Hausa","nativeName":"(Hausa) هَوُسَ","wikiUrl":"https://en.wikipedia.org/wiki/Hausa_language"},"he":{"639-1":"he","639-2":"heb","family":"Afro-Asiatic","name":"Hebrew (modern)","nativeName":"עברית","wikiUrl":"https://en.wikipedia.org/wiki/Hebrew_language"},"hi":{"639-1":"hi","639-2":"hin","family":"Indo-European","name":"Hindi","nativeName":"हिन्दी, हिंदी","wikiUrl":"https://en.wikipedia.org/wiki/Hindi"},"ho":{"639-1":"ho","639-2":"hmo","family":"Austronesian","name":"Hiri Motu","nativeName":"Hiri Motu","wikiUrl":"https://en.wikipedia.org/wiki/Hiri_Motu_language"},"hr":{"639-1":"hr","639-2":"hrv","family":"Indo-European","name":"Croatian","nativeName":"hrvatski jezik","wikiUrl":"https://en.wikipedia.org/wiki/Croatian_language"},"ht":{"639-1":"ht","639-2":"hat","family":"Creole","name":"Haitian, Haitian Creole","nativeName":"Kreyòl ayisyen","wikiUrl":"https://en.wikipedia.org/wiki/Haitian_Creole_language"},"hu":{"639-1":"hu","639-2":"hun","family":"Uralic","name":"Hungarian","nativeName":"magyar","wikiUrl":"https://en.wikipedia.org/wiki/Hungarian_language"},"hy":{"639-1":"hy","639-2":"hye","639-2/B":"arm","family":"Indo-European","name":"Armenian","nativeName":"Հայերեն","wikiUrl":"https://en.wikipedia.org/wiki/Armenian_language"},"hz":{"639-1":"hz","639-2":"her","family":"Niger–Congo","name":"Herero","nativeName":"Otjiherero","wikiUrl":"https://en.wikipedia.org/wiki/Herero_language"},"ia":{"639-1":"ia","639-2":"ina","family":"Constructed","name":"Interlingua","nativeName":"Interlingua","wikiUrl":"https://en.wikipedia.org/wiki/Interlingua"},"id":{"639-1":"id","639-2":"ind","family":"Austronesian","name":"Indonesian","nativeName":"Bahasa Indonesia","wikiUrl":"https://en.wikipedia.org/wiki/Indonesian_language"},"ie":{"639-1":"ie","639-2":"ile","family":"Constructed","name":"Interlingue","nativeName":"Originally called Occidental; then Interlingue after WWII","wikiUrl":"https://en.wikipedia.org/wiki/Interlingue_language"},"ig":{"639-1":"ig","639-2":"ibo","family":"Niger–Congo","name":"Igbo","nativeName":"Asụsụ Igbo","wikiUrl":"https://en.wikipedia.org/wiki/Igbo_language"},"ii":{"639-1":"ii","639-2":"iii","family":"Sino-Tibetan","name":"Nuosu","nativeName":"ꆈꌠ꒿ Nuosuhxop","wikiUrl":"https://en.wikipedia.org/wiki/Nuosu_language"},"ik":{"639-1":"ik","639-2":"ipk","family":"Eskimo–Aleut","name":"Inupiaq","nativeName":"Iñupiaq, Iñupiatun","wikiUrl":"https://en.wikipedia.org/wiki/Inupiaq_language"},"io":{"639-1":"io","639-2":"ido","family":"Constructed","name":"Ido","nativeName":"Ido","wikiUrl":"https://en.wikipedia.org/wiki/Ido_(language)"},"is":{"639-1":"is","639-2":"isl","639-2/B":"ice","family":"Indo-European","name":"Icelandic","nativeName":"Íslenska","wikiUrl":"https://en.wikipedia.org/wiki/Icelandic_language"},"it":{"639-1":"it","639-2":"ita","family":"Indo-European","name":"Italian","nativeName":"Italiano","wikiUrl":"https://en.wikipedia.org/wiki/Italian_language"},"iu":{"639-1":"iu","639-2":"iku","family":"Eskimo–Aleut","name":"Inuktitut","nativeName":"ᐃᓄᒃᑎᑐᑦ","wikiUrl":"https://en.wikipedia.org/wiki/Inuktitut"},"ja":{"639-1":"ja","639-2":"jpn","family":"Japonic","name":"Japanese","nativeName":"日本語 (にほんご)","wikiUrl":"https://en.wikipedia.org/wiki/Japanese_language"},"jv":{"639-1":"jv","639-2":"jav","family":"Austronesian","name":"Javanese","nativeName":"ꦧꦱꦗꦮ, Basa Jawa","wikiUrl":"https://en.wikipedia.org/wiki/Javanese_language"},"ka":{"639-1":"ka","639-2":"kat","639-2/B":"geo","family":"South Caucasian","name":"Georgian","nativeName":"ქართული","wikiUrl":"https://en.wikipedia.org/wiki/Georgian_language"},"kg":{"639-1":"kg","639-2":"kon","family":"Niger–Congo","name":"Kongo","nativeName":"Kikongo","wikiUrl":"https://en.wikipedia.org/wiki/Kongo_language"},"ki":{"639-1":"ki","639-2":"kik","family":"Niger–Congo","name":"Kikuyu, Gikuyu","nativeName":"Gĩkũyũ","wikiUrl":"https://en.wikipedia.org/wiki/Gikuyu_language"},"kj":{"639-1":"kj","639-2":"kua","family":"Niger–Congo","name":"Kwanyama, Kuanyama","nativeName":"Kuanyama","wikiUrl":"https://en.wikipedia.org/wiki/Kwanyama"},"kk":{"639-1":"kk","639-2":"kaz","family":"Turkic","name":"Kazakh","nativeName":"қазақ тілі","wikiUrl":"https://en.wikipedia.org/wiki/Kazakh_language"},"kl":{"639-1":"kl","639-2":"kal","family":"Eskimo–Aleut","name":"Kalaallisut, Greenlandic","nativeName":"kalaallisut, kalaallit oqaasii","wikiUrl":"https://en.wikipedia.org/wiki/Kalaallisut_language"},"km":{"639-1":"km","639-2":"khm","family":"Austroasiatic","name":"Khmer","nativeName":"ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ","wikiUrl":"https://en.wikipedia.org/wiki/Khmer_language"},"kn":{"639-1":"kn","639-2":"kan","family":"Dravidian","name":"Kannada","nativeName":"ಕನ್ನಡ","wikiUrl":"https://en.wikipedia.org/wiki/Kannada_language"},"ko":{"639-1":"ko","639-2":"kor","family":"Koreanic","name":"Korean","nativeName":"한국어","wikiUrl":"https://en.wikipedia.org/wiki/Korean_language"},"kr":{"639-1":"kr","639-2":"kau","family":"Nilo-Saharan","name":"Kanuri","nativeName":"Kanuri","wikiUrl":"https://en.wikipedia.org/wiki/Kanuri_language"},"ks":{"639-1":"ks","639-2":"kas","family":"Indo-European","name":"Kashmiri","nativeName":"कश्मीरी, كشميري‎","wikiUrl":"https://en.wikipedia.org/wiki/Kashmiri_language"},"ku":{"639-1":"ku","639-2":"kur","family":"Indo-European","name":"Kurdish","nativeName":"Kurdî, كوردی‎","wikiUrl":"https://en.wikipedia.org/wiki/Kurdish_language"},"kv":{"639-1":"kv","639-2":"kom","family":"Uralic","name":"Komi","nativeName":"коми кыв","wikiUrl":"https://en.wikipedia.org/wiki/Komi_language"},"kw":{"639-1":"kw","639-2":"cor","family":"Indo-European","name":"Cornish","nativeName":"Kernewek","wikiUrl":"https://en.wikipedia.org/wiki/Cornish_language"},"ky":{"639-1":"ky","639-2":"kir","family":"Turkic","name":"Kyrgyz","nativeName":"Кыргызча, Кыргыз тили","wikiUrl":"https://en.wikipedia.org/wiki/Kyrgyz_language"},"la":{"639-1":"la","639-2":"lat","family":"Indo-European","name":"Latin","nativeName":"latine, lingua latina","wikiUrl":"https://en.wikipedia.org/wiki/Latin"},"lb":{"639-1":"lb","639-2":"ltz","family":"Indo-European","name":"Luxembourgish, Letzeburgesch","nativeName":"Lëtzebuergesch","wikiUrl":"https://en.wikipedia.org/wiki/Luxembourgish_language"},"lg":{"639-1":"lg","639-2":"lug","family":"Niger–Congo","name":"Ganda","nativeName":"Luganda","wikiUrl":"https://en.wikipedia.org/wiki/Luganda"},"li":{"639-1":"li","639-2":"lim","family":"Indo-European","name":"Limburgish, Limburgan, Limburger","nativeName":"Limburgs","wikiUrl":"https://en.wikipedia.org/wiki/Limburgish_language"},"ln":{"639-1":"ln","639-2":"lin","family":"Niger–Congo","name":"Lingala","nativeName":"Lingála","wikiUrl":"https://en.wikipedia.org/wiki/Lingala_language"},"lo":{"639-1":"lo","639-2":"lao","family":"Tai–Kadai","name":"Lao","nativeName":"ພາສາລາວ","wikiUrl":"https://en.wikipedia.org/wiki/Lao_language"},"lt":{"639-1":"lt","639-2":"lit","family":"Indo-European","name":"Lithuanian","nativeName":"lietuvių kalba","wikiUrl":"https://en.wikipedia.org/wiki/Lithuanian_language"},"lu":{"639-1":"lu","639-2":"lub","family":"Niger–Congo","name":"Luba-Katanga","nativeName":"Tshiluba","wikiUrl":"https://en.wikipedia.org/wiki/Tshiluba_language"},"lv":{"639-1":"lv","639-2":"lav","family":"Indo-European","name":"Latvian","nativeName":"latviešu valoda","wikiUrl":"https://en.wikipedia.org/wiki/Latvian_language"},"mg":{"639-1":"mg","639-2":"mlg","family":"Austronesian","name":"Malagasy","nativeName":"fiteny malagasy","wikiUrl":"https://en.wikipedia.org/wiki/Malagasy_language"},"mh":{"639-1":"mh","639-2":"mah","family":"Austronesian","name":"Marshallese","nativeName":"Kajin M̧ajeļ","wikiUrl":"https://en.wikipedia.org/wiki/Marshallese_language"},"mi":{"639-1":"mi","639-2":"mri","639-2/B":"mao","family":"Austronesian","name":"Māori","nativeName":"te reo Māori","wikiUrl":"https://en.wikipedia.org/wiki/M%C4%81ori_language"},"mk":{"639-1":"mk","639-2":"mkd","639-2/B":"mac","family":"Indo-European","name":"Macedonian","nativeName":"македонски јазик","wikiUrl":"https://en.wikipedia.org/wiki/Macedonian_language"},"ml":{"639-1":"ml","639-2":"mal","family":"Dravidian","name":"Malayalam","nativeName":"മലയാളം","wikiUrl":"https://en.wikipedia.org/wiki/Malayalam_language"},"mn":{"639-1":"mn","639-2":"mon","family":"Mongolic","name":"Mongolian","nativeName":"Монгол хэл","wikiUrl":"https://en.wikipedia.org/wiki/Mongolian_language"},"mr":{"639-1":"mr","639-2":"mar","family":"Indo-European","name":"Marathi (Marāṭhī)","nativeName":"मराठी","wikiUrl":"https://en.wikipedia.org/wiki/Marathi_language"},"ms":{"639-1":"ms","639-2":"msa","639-2/B":"may","family":"Austronesian","name":"Malay","nativeName":"bahasa Melayu, بهاس ملايو‎","wikiUrl":"https://en.wikipedia.org/wiki/Malay_language"},"mt":{"639-1":"mt","639-2":"mlt","family":"Afro-Asiatic","name":"Maltese","nativeName":"Malti","wikiUrl":"https://en.wikipedia.org/wiki/Maltese_language"},"my":{"639-1":"my","639-2":"mya","639-2/B":"bur","family":"Sino-Tibetan","name":"Burmese","nativeName":"ဗမာစာ","wikiUrl":"https://en.wikipedia.org/wiki/Burmese_language"},"na":{"639-1":"na","639-2":"nau","family":"Austronesian","name":"Nauruan","nativeName":"Dorerin Naoero","wikiUrl":"https://en.wikipedia.org/wiki/Nauruan_language"},"nb":{"639-1":"nb","639-2":"nob","family":"Indo-European","name":"Norwegian Bokmål","nativeName":"Norsk bokmål","wikiUrl":"https://en.wikipedia.org/wiki/Bokm%C3%A5l"},"nd":{"639-1":"nd","639-2":"nde","family":"Niger–Congo","name":"Northern Ndebele","nativeName":"isiNdebele","wikiUrl":"https://en.wikipedia.org/wiki/Northern_Ndebele_language"},"ne":{"639-1":"ne","639-2":"nep","family":"Indo-European","name":"Nepali","nativeName":"नेपाली","wikiUrl":"https://en.wikipedia.org/wiki/Nepali_language"},"ng":{"639-1":"ng","639-2":"ndo","family":"Niger–Congo","name":"Ndonga","nativeName":"Owambo","wikiUrl":"https://en.wikipedia.org/wiki/Ndonga"},"nl":{"639-1":"nl","639-2":"nld","639-2/B":"dut","family":"Indo-European","name":"Dutch","nativeName":"Nederlands, Vlaams","wikiUrl":"https://en.wikipedia.org/wiki/Dutch_language"},"nn":{"639-1":"nn","639-2":"nno","family":"Indo-European","name":"Norwegian Nynorsk","nativeName":"Norsk nynorsk","wikiUrl":"https://en.wikipedia.org/wiki/Nynorsk"},"no":{"639-1":"no","639-2":"nor","family":"Indo-European","name":"Norwegian","nativeName":"Norsk","wikiUrl":"https://en.wikipedia.org/wiki/Norwegian_language"},"nr":{"639-1":"nr","639-2":"nbl","family":"Niger–Congo","name":"Southern Ndebele","nativeName":"isiNdebele","wikiUrl":"https://en.wikipedia.org/wiki/Southern_Ndebele_language"},"nv":{"639-1":"nv","639-2":"nav","family":"Dené–Yeniseian","name":"Navajo, Navaho","nativeName":"Diné bizaad","wikiUrl":"https://en.wikipedia.org/wiki/Navajo_language"},"ny":{"639-1":"ny","639-2":"nya","family":"Niger–Congo","name":"Chichewa, Chewa, Nyanja","nativeName":"chiCheŵa, chinyanja","wikiUrl":"https://en.wikipedia.org/wiki/Chichewa_language"},"oc":{"639-1":"oc","639-2":"oci","family":"Indo-European","name":"Occitan","nativeName":"occitan, lenga d'òc","wikiUrl":"https://en.wikipedia.org/wiki/Occitan_language"},"oj":{"639-1":"oj","639-2":"oji","family":"Algonquian","name":"Ojibwe, Ojibwa","nativeName":"ᐊᓂᔑᓈᐯᒧᐎᓐ","wikiUrl":"https://en.wikipedia.org/wiki/Ojibwe_language"},"om":{"639-1":"om","639-2":"orm","family":"Afro-Asiatic","name":"Oromo","nativeName":"Afaan Oromoo","wikiUrl":"https://en.wikipedia.org/wiki/Oromo_language"},"or":{"639-1":"or","639-2":"ori","family":"Indo-European","name":"Oriya","nativeName":"ଓଡ଼ିଆ","wikiUrl":"https://en.wikipedia.org/wiki/Oriya_language"},"os":{"639-1":"os","639-2":"oss","family":"Indo-European","name":"Ossetian, Ossetic","nativeName":"ирон æвзаг","wikiUrl":"https://en.wikipedia.org/wiki/Ossetic_language"},"pa":{"639-1":"pa","639-2":"pan","family":"Indo-European","name":"(Eastern) Punjabi","nativeName":"ਪੰਜਾਬੀ","wikiUrl":"https://en.wikipedia.org/wiki/Eastern_Punjabi_language"},"pi":{"639-1":"pi","639-2":"pli","family":"Indo-European","name":"Pāli","nativeName":"पाऴि","wikiUrl":"https://en.wikipedia.org/wiki/P%C4%81li_language"},"pl":{"639-1":"pl","639-2":"pol","family":"Indo-European","name":"Polish","nativeName":"język polski, polszczyzna","wikiUrl":"https://en.wikipedia.org/wiki/Polish_language"},"ps":{"639-1":"ps","639-2":"pus","family":"Indo-European","name":"Pashto, Pushto","nativeName":"پښتو","wikiUrl":"https://en.wikipedia.org/wiki/Pashto_language"},"pt":{"639-1":"pt","639-2":"por","family":"Indo-European","name":"Portuguese","nativeName":"Português","wikiUrl":"https://en.wikipedia.org/wiki/Portuguese_language"},"qu":{"639-1":"qu","639-2":"que","family":"Quechuan","name":"Quechua","nativeName":"Runa Simi, Kichwa","wikiUrl":"https://en.wikipedia.org/wiki/Quechua_language"},"rm":{"639-1":"rm","639-2":"roh","family":"Indo-European","name":"Romansh","nativeName":"rumantsch grischun","wikiUrl":"https://en.wikipedia.org/wiki/Romansh_language"},"rn":{"639-1":"rn","639-2":"run","family":"Niger–Congo","name":"Kirundi","nativeName":"Ikirundi","wikiUrl":"https://en.wikipedia.org/wiki/Kirundi"},"ro":{"639-1":"ro","639-2":"ron","639-2/B":"rum","family":"Indo-European","name":"Romanian","nativeName":"Română","wikiUrl":"https://en.wikipedia.org/wiki/Romanian_language"},"ru":{"639-1":"ru","639-2":"rus","family":"Indo-European","name":"Russian","nativeName":"Русский","wikiUrl":"https://en.wikipedia.org/wiki/Russian_language"},"rw":{"639-1":"rw","639-2":"kin","family":"Niger–Congo","name":"Kinyarwanda","nativeName":"Ikinyarwanda","wikiUrl":"https://en.wikipedia.org/wiki/Kinyarwanda"},"sa":{"639-1":"sa","639-2":"san","family":"Indo-European","name":"Sanskrit (Saṁskṛta)","nativeName":"संस्कृतम्","wikiUrl":"https://en.wikipedia.org/wiki/Sanskrit"},"sc":{"639-1":"sc","639-2":"srd","family":"Indo-European","name":"Sardinian","nativeName":"sardu","wikiUrl":"https://en.wikipedia.org/wiki/Sardinian_language"},"sd":{"639-1":"sd","639-2":"snd","family":"Indo-European","name":"Sindhi","nativeName":"सिन्धी, سنڌي، سندھی‎","wikiUrl":"https://en.wikipedia.org/wiki/Sindhi_language"},"se":{"639-1":"se","639-2":"sme","family":"Uralic","name":"Northern Sami","nativeName":"Davvisámegiella","wikiUrl":"https://en.wikipedia.org/wiki/Northern_Sami"},"sg":{"639-1":"sg","639-2":"sag","family":"Creole","name":"Sango","nativeName":"yângâ tî sängö","wikiUrl":"https://en.wikipedia.org/wiki/Sango_language"},"si":{"639-1":"si","639-2":"sin","family":"Indo-European","name":"Sinhalese, Sinhala","nativeName":"සිංහල","wikiUrl":"https://en.wikipedia.org/wiki/Sinhalese_language"},"sk":{"639-1":"sk","639-2":"slk","639-2/B":"slo","family":"Indo-European","name":"Slovak","nativeName":"slovenčina, slovenský jazyk","wikiUrl":"https://en.wikipedia.org/wiki/Slovak_language"},"sl":{"639-1":"sl","639-2":"slv","family":"Indo-European","name":"Slovene","nativeName":"slovenski jezik, slovenščina","wikiUrl":"https://en.wikipedia.org/wiki/Slovene_language"},"sm":{"639-1":"sm","639-2":"smo","family":"Austronesian","name":"Samoan","nativeName":"gagana fa'a Samoa","wikiUrl":"https://en.wikipedia.org/wiki/Samoan_language"},"sn":{"639-1":"sn","639-2":"sna","family":"Niger–Congo","name":"Shona","nativeName":"chiShona","wikiUrl":"https://en.wikipedia.org/wiki/Shona_language"},"so":{"639-1":"so","639-2":"som","family":"Afro-Asiatic","name":"Somali","nativeName":"Soomaaliga, af Soomaali","wikiUrl":"https://en.wikipedia.org/wiki/Somali_language"},"sq":{"639-1":"sq","639-2":"sqi","639-2/B":"alb","family":"Indo-European","name":"Albanian","nativeName":"Shqip","wikiUrl":"https://en.wikipedia.org/wiki/Albanian_language"},"sr":{"639-1":"sr","639-2":"srp","family":"Indo-European","name":"Serbian","nativeName":"српски језик","wikiUrl":"https://en.wikipedia.org/wiki/Serbian_language"},"ss":{"639-1":"ss","639-2":"ssw","family":"Niger–Congo","name":"Swati","nativeName":"SiSwati","wikiUrl":"https://en.wikipedia.org/wiki/Swati_language"},"st":{"639-1":"st","639-2":"sot","family":"Niger–Congo","name":"Southern Sotho","nativeName":"Sesotho","wikiUrl":"https://en.wikipedia.org/wiki/Sotho_language"},"su":{"639-1":"su","639-2":"sun","family":"Austronesian","name":"Sundanese","nativeName":"Basa Sunda","wikiUrl":"https://en.wikipedia.org/wiki/Sundanese_language"},"sv":{"639-1":"sv","639-2":"swe","family":"Indo-European","name":"Swedish","nativeName":"svenska","wikiUrl":"https://en.wikipedia.org/wiki/Swedish_language"},"sw":{"639-1":"sw","639-2":"swa","family":"Niger–Congo","name":"Swahili","nativeName":"Kiswahili","wikiUrl":"https://en.wikipedia.org/wiki/Swahili_language"},"ta":{"639-1":"ta","639-2":"tam","family":"Dravidian","name":"Tamil","nativeName":"தமிழ்","wikiUrl":"https://en.wikipedia.org/wiki/Tamil_language"},"te":{"639-1":"te","639-2":"tel","family":"Dravidian","name":"Telugu","nativeName":"తెలుగు","wikiUrl":"https://en.wikipedia.org/wiki/Telugu_language"},"tg":{"639-1":"tg","639-2":"tgk","family":"Indo-European","name":"Tajik","nativeName":"тоҷикӣ, toçikī, تاجیکی‎","wikiUrl":"https://en.wikipedia.org/wiki/Tajik_language"},"th":{"639-1":"th","639-2":"tha","family":"Tai–Kadai","name":"Thai","nativeName":"ไทย","wikiUrl":"https://en.wikipedia.org/wiki/Thai_language"},"ti":{"639-1":"ti","639-2":"tir","family":"Afro-Asiatic","name":"Tigrinya","nativeName":"ትግርኛ","wikiUrl":"https://en.wikipedia.org/wiki/Tigrinya_language"},"tk":{"639-1":"tk","639-2":"tuk","family":"Turkic","name":"Turkmen","nativeName":"Türkmen, Түркмен","wikiUrl":"https://en.wikipedia.org/wiki/Turkmen_language"},"tl":{"639-1":"tl","639-2":"tgl","family":"Austronesian","name":"Tagalog","nativeName":"Wikang Tagalog","wikiUrl":"https://en.wikipedia.org/wiki/Tagalog_language"},"tn":{"639-1":"tn","639-2":"tsn","family":"Niger–Congo","name":"Tswana","nativeName":"Setswana","wikiUrl":"https://en.wikipedia.org/wiki/Tswana_language"},"to":{"639-1":"to","639-2":"ton","family":"Austronesian","name":"Tonga (Tonga Islands)","nativeName":"faka Tonga","wikiUrl":"https://en.wikipedia.org/wiki/Tongan_language"},"tr":{"639-1":"tr","639-2":"tur","family":"Turkic","name":"Turkish","nativeName":"Türkçe","wikiUrl":"https://en.wikipedia.org/wiki/Turkish_language"},"ts":{"639-1":"ts","639-2":"tso","family":"Niger–Congo","name":"Tsonga","nativeName":"Xitsonga","wikiUrl":"https://en.wikipedia.org/wiki/Tsonga_language"},"tt":{"639-1":"tt","639-2":"tat","family":"Turkic","name":"Tatar","nativeName":"татар теле, tatar tele","wikiUrl":"https://en.wikipedia.org/wiki/Tatar_language"},"tw":{"639-1":"tw","639-2":"twi","family":"Niger–Congo","name":"Twi","nativeName":"Twi","wikiUrl":"https://en.wikipedia.org/wiki/Twi"},"ty":{"639-1":"ty","639-2":"tah","family":"Austronesian","name":"Tahitian","nativeName":"Reo Tahiti","wikiUrl":"https://en.wikipedia.org/wiki/Tahitian_language"},"ug":{"639-1":"ug","639-2":"uig","family":"Turkic","name":"Uyghur","nativeName":"ئۇيغۇرچە‎, Uyghurche","wikiUrl":"https://en.wikipedia.org/wiki/Uyghur_language"},"uk":{"639-1":"uk","639-2":"ukr","family":"Indo-European","name":"Ukrainian","nativeName":"Українська","wikiUrl":"https://en.wikipedia.org/wiki/Ukrainian_language"},"ur":{"639-1":"ur","639-2":"urd","family":"Indo-European","name":"Urdu","nativeName":"اردو","wikiUrl":"https://en.wikipedia.org/wiki/Urdu"},"uz":{"639-1":"uz","639-2":"uzb","family":"Turkic","name":"Uzbek","nativeName":"Oʻzbek, Ўзбек, أۇزبېك‎","wikiUrl":"https://en.wikipedia.org/wiki/Uzbek_language"},"ve":{"639-1":"ve","639-2":"ven","family":"Niger–Congo","name":"Venda","nativeName":"Tshivenḓa","wikiUrl":"https://en.wikipedia.org/wiki/Venda_language"},"vi":{"639-1":"vi","639-2":"vie","family":"Austroasiatic","name":"Vietnamese","nativeName":"Tiếng Việt","wikiUrl":"https://en.wikipedia.org/wiki/Vietnamese_language"},"vo":{"639-1":"vo","639-2":"vol","family":"Constructed","name":"Volapük","nativeName":"Volapük","wikiUrl":"https://en.wikipedia.org/wiki/Volap%C3%BCk"},"wa":{"639-1":"wa","639-2":"wln","family":"Indo-European","name":"Walloon","nativeName":"walon","wikiUrl":"https://en.wikipedia.org/wiki/Walloon_language"},"wo":{"639-1":"wo","639-2":"wol","family":"Niger–Congo","name":"Wolof","nativeName":"Wollof","wikiUrl":"https://en.wikipedia.org/wiki/Wolof_language"},"xh":{"639-1":"xh","639-2":"xho","family":"Niger–Congo","name":"Xhosa","nativeName":"isiXhosa","wikiUrl":"https://en.wikipedia.org/wiki/Xhosa_language"},"yi":{"639-1":"yi","639-2":"yid","family":"Indo-European","name":"Yiddish","nativeName":"ייִדיש","wikiUrl":"https://en.wikipedia.org/wiki/Yiddish_language"},"yo":{"639-1":"yo","639-2":"yor","family":"Niger–Congo","name":"Yoruba","nativeName":"Yorùbá","wikiUrl":"https://en.wikipedia.org/wiki/Yoruba_language"},"za":{"639-1":"za","639-2":"zha","family":"Tai–Kadai","name":"Zhuang, Chuang","nativeName":"Saɯ cueŋƅ, Saw cuengh","wikiUrl":"https://en.wikipedia.org/wiki/Zhuang_languages"},"zh":{"639-1":"zh","639-2":"zho","639-2/B":"chi","family":"Sino-Tibetan","name":"Chinese","nativeName":"中文 (Zhōngwén), 汉语, 漢語","wikiUrl":"https://en.wikipedia.org/wiki/Chinese_language"},"zu":{"639-1":"zu","639-2":"zul","family":"Niger–Congo","name":"Zulu","nativeName":"isiZulu","wikiUrl":"https://en.wikipedia.org/wiki/Zulu_language"}} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..4039870a --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:io' show Platform; +import 'package:media_kit/media_kit.dart'; +import 'package:window_manager/window_manager.dart'; +import 'client/plex_client.dart'; +import 'config/plex_config.dart'; +import 'screens/main_screen.dart'; +import 'screens/auth_screen.dart'; +import 'services/storage_service.dart'; +import 'services/plex_auth_service.dart'; +import 'services/macos_titlebar_service.dart'; +import 'services/fullscreen_state_manager.dart'; +import 'models/plex_user_profile.dart'; +import 'utils/language_codes.dart'; +import 'utils/app_logger.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize window_manager for desktop platforms + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + await windowManager.ensureInitialized(); + } + + // Configure macOS window with custom titlebar + await MacOSTitlebarService.setupCustomTitlebar(); + + // Initialize MediaKit + MediaKit.ensureInitialized(); + + // Lock orientation to portrait for all screens except video player + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + await StorageService.getInstance(); + + // Initialize language codes for track selection + await LanguageCodes.initialize(); + + // Start global fullscreen state monitoring + FullscreenStateManager().startMonitoring(); + + // DTD service is available for MCP tooling connection if needed + + runApp(const MainApp()); +} + +// Global RouteObserver for tracking navigation +final RouteObserver routeObserver = RouteObserver(); + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Plezy', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange), + useMaterial3: true, + ), + darkTheme: ThemeData.dark(useMaterial3: true), + navigatorObservers: [routeObserver], + home: const SetupScreen(), + ); + } +} + +class SetupScreen extends StatefulWidget { + const SetupScreen({super.key}); + + @override + State createState() => _SetupScreenState(); +} + +class _SetupScreenState extends State { + @override + void initState() { + super.initState(); + _loadSavedCredentials(); + } + + Future _loadSavedCredentials() async { + final storage = await StorageService.getInstance(); + + // Check if we have server data + final serverData = storage.getServerData(); + final clientId = storage.getClientIdentifier(); + final plexToken = storage.getPlexToken(); + + if (serverData != null && clientId != null) { + try { + // Recreate PlexServer from stored data + final server = PlexServer.fromJson(serverData); + + // Test connections to find best working one + final connection = await server.findBestWorkingConnection(); + + if (connection != null) { + // Update stored server URL with working connection + await storage.saveServerUrl(connection.uri); + + // Create client with working connection + final config = PlexConfig( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientId, + ); + final client = PlexClient(config); + + // Verify server is accessible + try { + await client.getServerIdentity(); + + // Fetch and cache user profile if we have a plex token + PlexUserProfile? userProfile; + if (plexToken != null) { + userProfile = await _fetchAndCacheUserProfile(plexToken); + } + + // Success! Navigate to main screen + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + MainScreen(client: client, userProfile: userProfile), + ), + ); + return; + } + } catch (e) { + // Server identity check failed + await storage.clearCredentials(); + } + } else { + // No working connections found + await storage.clearCredentials(); + } + } catch (e) { + // Error loading or testing server + await storage.clearCredentials(); + } + } + + // No saved credentials or auto-login failed - show auth screen + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const AuthScreen()), + ); + } + } + + Future _fetchAndCacheUserProfile(String plexToken) async { + appLogger.d('Fetching user profile from Plex API'); + try { + final authService = await PlexAuthService.create(); + final profile = await authService.getUserProfile(plexToken); + + appLogger.i( + 'Successfully fetched user profile', + error: { + 'autoSelectAudio': profile.autoSelectAudio, + 'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set', + 'autoSelectSubtitle': profile.autoSelectSubtitle, + 'defaultSubtitleLanguage': + profile.defaultSubtitleLanguage ?? 'not set', + 'defaultSubtitleForced': profile.defaultSubtitleForced, + }, + ); + + // Cache the profile + final storage = await StorageService.getInstance(); + await storage.saveUserProfile(profile.toJson()); + appLogger.d('User profile cached locally'); + + return profile; + } catch (e) { + appLogger.w( + 'Failed to fetch user profile from API, attempting to load from cache', + error: e, + ); + + // Failed to fetch profile, try to load from cache + final storage = await StorageService.getInstance(); + final cachedProfile = storage.getUserProfile(); + if (cachedProfile != null) { + final profile = PlexUserProfile.fromJson(cachedProfile); + appLogger.i( + 'Loaded user profile from cache', + error: { + 'autoSelectAudio': profile.autoSelectAudio, + 'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set', + 'autoSelectSubtitle': profile.autoSelectSubtitle, + 'defaultSubtitleLanguage': + profile.defaultSubtitleLanguage ?? 'not set', + 'defaultSubtitleForced': profile.defaultSubtitleForced, + }, + ); + return profile; + } + + appLogger.w( + 'No cached user profile available, track selection will use defaults', + ); + return null; + } + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ); + } +} diff --git a/lib/mixins/refreshable.dart b/lib/mixins/refreshable.dart new file mode 100644 index 00000000..065e83e4 --- /dev/null +++ b/lib/mixins/refreshable.dart @@ -0,0 +1,3 @@ +mixin Refreshable { + void refresh(); +} diff --git a/lib/models/media_container.dart b/lib/models/media_container.dart new file mode 100644 index 00000000..f77a29fe --- /dev/null +++ b/lib/models/media_container.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'plex_metadata.dart'; +import 'plex_library.dart'; + +part 'media_container.g.dart'; + +@JsonSerializable() +class MediaContainer { + final int? size; + final int? totalSize; + final int? offset; + final String? identifier; + @JsonKey(name: 'Directory') + final List? directories; + @JsonKey(name: 'Metadata') + final List? metadata; + + MediaContainer({ + this.size, + this.totalSize, + this.offset, + this.identifier, + this.directories, + this.metadata, + }); + + factory MediaContainer.fromJson(Map json) => + _$MediaContainerFromJson(json); + + Map toJson() => _$MediaContainerToJson(this); +} diff --git a/lib/models/media_container.g.dart b/lib/models/media_container.g.dart new file mode 100644 index 00000000..2ed4e9e0 --- /dev/null +++ b/lib/models/media_container.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_container.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MediaContainer _$MediaContainerFromJson(Map json) => + MediaContainer( + size: (json['size'] as num?)?.toInt(), + totalSize: (json['totalSize'] as num?)?.toInt(), + offset: (json['offset'] as num?)?.toInt(), + identifier: json['identifier'] as String?, + directories: (json['Directory'] as List?) + ?.map((e) => PlexLibrary.fromJson(e as Map)) + .toList(), + metadata: (json['Metadata'] as List?) + ?.map((e) => PlexMetadata.fromJson(e as Map)) + .toList(), + ); + +Map _$MediaContainerToJson(MediaContainer instance) => + { + 'size': instance.size, + 'totalSize': instance.totalSize, + 'offset': instance.offset, + 'identifier': instance.identifier, + 'Directory': instance.directories, + 'Metadata': instance.metadata, + }; diff --git a/lib/models/plex_filter.dart b/lib/models/plex_filter.dart new file mode 100644 index 00000000..6f76a14a --- /dev/null +++ b/lib/models/plex_filter.dart @@ -0,0 +1,55 @@ +class PlexFilter { + final String filter; + final String filterType; + final String key; + final String title; + final String type; + + PlexFilter({ + required this.filter, + required this.filterType, + required this.key, + required this.title, + required this.type, + }); + + factory PlexFilter.fromJson(Map json) { + return PlexFilter( + filter: json['filter'] ?? '', + filterType: json['filterType'] ?? 'string', + key: json['key'] ?? '', + title: json['title'] ?? '', + type: json['type'] ?? 'filter', + ); + } + + Map toJson() { + return { + 'filter': filter, + 'filterType': filterType, + 'key': key, + 'title': title, + 'type': type, + }; + } +} + +class PlexFilterValue { + final String key; + final String title; + final String? type; + + PlexFilterValue({required this.key, required this.title, this.type}); + + factory PlexFilterValue.fromJson(Map json) { + return PlexFilterValue( + key: json['key'] ?? '', + title: json['title'] ?? '', + type: json['type'], + ); + } + + Map toJson() { + return {'key': key, 'title': title, if (type != null) 'type': type}; + } +} diff --git a/lib/models/plex_library.dart b/lib/models/plex_library.dart new file mode 100644 index 00000000..10ee2f31 --- /dev/null +++ b/lib/models/plex_library.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_library.g.dart'; + +@JsonSerializable() +class PlexLibrary { + final String key; + final String title; + final String type; + final String? agent; + final String? scanner; + final String? language; + final String? uuid; + final int? updatedAt; + final int? createdAt; + + PlexLibrary({ + required this.key, + required this.title, + required this.type, + this.agent, + this.scanner, + this.language, + this.uuid, + this.updatedAt, + this.createdAt, + }); + + factory PlexLibrary.fromJson(Map json) => + _$PlexLibraryFromJson(json); + + Map toJson() => _$PlexLibraryToJson(this); +} diff --git a/lib/models/plex_library.g.dart b/lib/models/plex_library.g.dart new file mode 100644 index 00000000..1f77d21a --- /dev/null +++ b/lib/models/plex_library.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_library.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexLibrary _$PlexLibraryFromJson(Map json) => PlexLibrary( + key: json['key'] as String, + title: json['title'] as String, + type: json['type'] as String, + agent: json['agent'] as String?, + scanner: json['scanner'] as String?, + language: json['language'] as String?, + uuid: json['uuid'] as String?, + updatedAt: (json['updatedAt'] as num?)?.toInt(), + createdAt: (json['createdAt'] as num?)?.toInt(), +); + +Map _$PlexLibraryToJson(PlexLibrary instance) => + { + 'key': instance.key, + 'title': instance.title, + 'type': instance.type, + 'agent': instance.agent, + 'scanner': instance.scanner, + 'language': instance.language, + 'uuid': instance.uuid, + 'updatedAt': instance.updatedAt, + 'createdAt': instance.createdAt, + }; diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart new file mode 100644 index 00000000..ad8c7387 --- /dev/null +++ b/lib/models/plex_media_info.dart @@ -0,0 +1,104 @@ +class PlexMediaInfo { + final String videoUrl; + final List audioTracks; + final List subtitleTracks; + final List chapters; + + PlexMediaInfo({ + required this.videoUrl, + required this.audioTracks, + required this.subtitleTracks, + required this.chapters, + }); +} + +class PlexAudioTrack { + final int id; + final int? index; + final String? codec; + final String? language; + final String? languageCode; + final String? title; + final String? displayTitle; + final int? channels; + final bool selected; + + PlexAudioTrack({ + required this.id, + this.index, + this.codec, + this.language, + this.languageCode, + this.title, + this.displayTitle, + this.channels, + required this.selected, + }); + + String get label { + if (displayTitle != null) return displayTitle!; + final parts = []; + if (language != null) parts.add(language!); + if (codec != null) parts.add(codec!.toUpperCase()); + if (channels != null) parts.add('${channels!}ch'); + return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + } +} + +class PlexSubtitleTrack { + final int id; + final int? index; + final String? codec; + final String? language; + final String? languageCode; + final String? title; + final String? displayTitle; + final bool selected; + final bool forced; + final String? key; + + PlexSubtitleTrack({ + required this.id, + this.index, + this.codec, + this.language, + this.languageCode, + this.title, + this.displayTitle, + required this.selected, + required this.forced, + this.key, + }); + + String get label { + if (displayTitle != null) return displayTitle!; + final parts = []; + if (language != null) parts.add(language!); + if (forced) parts.add('Forced'); + return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + } +} + +class PlexChapter { + final int id; + final int? index; + final int? startTimeOffset; + final int? endTimeOffset; + final String? title; + final String? thumb; + + PlexChapter({ + required this.id, + this.index, + this.startTimeOffset, + this.endTimeOffset, + this.title, + this.thumb, + }); + + String get label => title ?? 'Chapter ${(index ?? 0) + 1}'; + + Duration get startTime => Duration(milliseconds: startTimeOffset ?? 0); + Duration? get endTime => + endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null; +} diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart new file mode 100644 index 00000000..4d5d8f39 --- /dev/null +++ b/lib/models/plex_metadata.dart @@ -0,0 +1,150 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_metadata.g.dart'; + +@JsonSerializable() +class PlexMetadata { + final String ratingKey; + final String key; + final String? guid; + final String? studio; + final String type; + final String title; + final String? contentRating; + final String? summary; + final int? rating; + final int? year; + final String? thumb; + final String? art; + final int? duration; + final int? addedAt; + final int? updatedAt; + final String? grandparentTitle; // Show title for episodes + final String? grandparentThumb; // Show poster for episodes + final String? grandparentArt; // Show art for episodes + final String? grandparentRatingKey; // Show rating key for episodes + final String? parentTitle; // Season title for episodes + final String? parentRatingKey; // Season rating key for episodes + final int? parentIndex; // Season number + final int? index; // Episode number + final String? grandparentTheme; // Show theme music + final int? viewOffset; // Resume position in ms + final int? viewCount; + final int? leafCount; // Total number of episodes in a series/season + final int? viewedLeafCount; // Number of watched episodes in a series/season + + // Transient field for clear logo (extracted from Image array) + String? _clearLogo; + String? get clearLogo => _clearLogo; + + PlexMetadata({ + required this.ratingKey, + required this.key, + this.guid, + this.studio, + required this.type, + required this.title, + this.contentRating, + this.summary, + this.rating, + this.year, + this.thumb, + this.art, + this.duration, + this.addedAt, + this.updatedAt, + this.grandparentTitle, + this.grandparentThumb, + this.grandparentArt, + this.grandparentRatingKey, + this.parentTitle, + this.parentRatingKey, + this.parentIndex, + this.index, + this.grandparentTheme, + this.viewOffset, + this.viewCount, + this.leafCount, + this.viewedLeafCount, + }); + + // Extract clearLogo from Image array in raw JSON + void _extractClearLogo(Map json) { + if (!json.containsKey('Image')) return; + + final images = json['Image'] as List?; + if (images == null) return; + + for (var image in images) { + if (image is Map && image['type'] == 'clearLogo') { + _clearLogo = image['url'] as String?; + return; + } + } + } + + // Custom factory that extracts clearLogo + factory PlexMetadata.fromJsonWithImages(Map json) { + final metadata = PlexMetadata.fromJson(json); + metadata._extractClearLogo(json); + return metadata; + } + + // Helper to get the display title (show name for episodes/seasons, title otherwise) + String get displayTitle { + final itemType = type.toLowerCase(); + + // For episodes and seasons, prefer grandparent title (show name) + if ((itemType == 'episode' || itemType == 'season') && + grandparentTitle != null) { + return grandparentTitle!; + } + // For seasons without grandparent, check if this IS the show (parentTitle might have show name) + if (itemType == 'season' && parentTitle != null) { + return parentTitle!; + } + return title; + } + + // Helper to get the subtitle (episode/season title) + String? get displaySubtitle { + final itemType = type.toLowerCase(); + + if (itemType == 'episode' || itemType == 'season') { + // If we showed grandparent/parent as title, show this item's title as subtitle + if (grandparentTitle != null || + (itemType == 'season' && parentTitle != null)) { + return title; + } + } + return null; + } + + // Helper to get the poster (show poster for episodes/seasons, thumb otherwise) + String? get posterThumb { + final itemType = type.toLowerCase(); + + // For episodes and seasons, prefer grandparent thumb (show poster) + if ((itemType == 'episode' || itemType == 'season') && + grandparentThumb != null) { + return grandparentThumb!; + } + return thumb; + } + + // Helper to determine if content is watched + bool get isWatched { + // For series/seasons, check if all episodes are watched + if (leafCount != null && viewedLeafCount != null) { + return viewedLeafCount! >= leafCount!; + } + + // For individual items (movies, episodes), check viewCount + return viewCount != null && viewCount! > 0; + } + + factory PlexMetadata.fromJson(Map json) => + _$PlexMetadataFromJson(json); + + Map toJson() => _$PlexMetadataToJson(this); +} diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart new file mode 100644 index 00000000..1d0901ac --- /dev/null +++ b/lib/models/plex_metadata.g.dart @@ -0,0 +1,70 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_metadata.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( + ratingKey: json['ratingKey'] as String, + key: json['key'] as String, + guid: json['guid'] as String?, + studio: json['studio'] as String?, + type: json['type'] as String, + title: json['title'] as String, + contentRating: json['contentRating'] as String?, + summary: json['summary'] as String?, + rating: (json['rating'] as num?)?.toInt(), + year: (json['year'] as num?)?.toInt(), + thumb: json['thumb'] as String?, + art: json['art'] as String?, + duration: (json['duration'] as num?)?.toInt(), + addedAt: (json['addedAt'] as num?)?.toInt(), + updatedAt: (json['updatedAt'] as num?)?.toInt(), + grandparentTitle: json['grandparentTitle'] as String?, + grandparentThumb: json['grandparentThumb'] as String?, + grandparentArt: json['grandparentArt'] as String?, + grandparentRatingKey: json['grandparentRatingKey'] as String?, + parentTitle: json['parentTitle'] as String?, + parentRatingKey: json['parentRatingKey'] as String?, + parentIndex: (json['parentIndex'] as num?)?.toInt(), + index: (json['index'] as num?)?.toInt(), + grandparentTheme: json['grandparentTheme'] as String?, + viewOffset: (json['viewOffset'] as num?)?.toInt(), + viewCount: (json['viewCount'] as num?)?.toInt(), + leafCount: (json['leafCount'] as num?)?.toInt(), + viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(), +); + +Map _$PlexMetadataToJson(PlexMetadata instance) => + { + 'ratingKey': instance.ratingKey, + 'key': instance.key, + 'guid': instance.guid, + 'studio': instance.studio, + 'type': instance.type, + 'title': instance.title, + 'contentRating': instance.contentRating, + 'summary': instance.summary, + 'rating': instance.rating, + 'year': instance.year, + 'thumb': instance.thumb, + 'art': instance.art, + 'duration': instance.duration, + 'addedAt': instance.addedAt, + 'updatedAt': instance.updatedAt, + 'grandparentTitle': instance.grandparentTitle, + 'grandparentThumb': instance.grandparentThumb, + 'grandparentArt': instance.grandparentArt, + 'grandparentRatingKey': instance.grandparentRatingKey, + 'parentTitle': instance.parentTitle, + 'parentRatingKey': instance.parentRatingKey, + 'parentIndex': instance.parentIndex, + 'index': instance.index, + 'grandparentTheme': instance.grandparentTheme, + 'viewOffset': instance.viewOffset, + 'viewCount': instance.viewCount, + 'leafCount': instance.leafCount, + 'viewedLeafCount': instance.viewedLeafCount, + }; diff --git a/lib/models/plex_server_info.dart b/lib/models/plex_server_info.dart new file mode 100644 index 00000000..35b586db --- /dev/null +++ b/lib/models/plex_server_info.dart @@ -0,0 +1,29 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_server_info.g.dart'; + +@JsonSerializable() +class PlexServerInfo { + final String name; + final String? host; + final int? port; + final String? machineIdentifier; + final String version; + final bool? owned; + final bool? https; + + PlexServerInfo({ + required this.name, + this.host, + this.port, + this.machineIdentifier, + required this.version, + this.owned, + this.https, + }); + + factory PlexServerInfo.fromJson(Map json) => + _$PlexServerInfoFromJson(json); + + Map toJson() => _$PlexServerInfoToJson(this); +} diff --git a/lib/models/plex_server_info.g.dart b/lib/models/plex_server_info.g.dart new file mode 100644 index 00000000..d36d3126 --- /dev/null +++ b/lib/models/plex_server_info.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_server_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexServerInfo _$PlexServerInfoFromJson(Map json) => + PlexServerInfo( + name: json['name'] as String, + host: json['host'] as String?, + port: (json['port'] as num?)?.toInt(), + machineIdentifier: json['machineIdentifier'] as String?, + version: json['version'] as String, + owned: json['owned'] as bool?, + https: json['https'] as bool?, + ); + +Map _$PlexServerInfoToJson(PlexServerInfo instance) => + { + 'name': instance.name, + 'host': instance.host, + 'port': instance.port, + 'machineIdentifier': instance.machineIdentifier, + 'version': instance.version, + 'owned': instance.owned, + 'https': instance.https, + }; diff --git a/lib/models/plex_user_profile.dart b/lib/models/plex_user_profile.dart new file mode 100644 index 00000000..5c0e0424 --- /dev/null +++ b/lib/models/plex_user_profile.dart @@ -0,0 +1,83 @@ +/// Represents a Plex user's profile preferences +/// Fetched from https://clients.plex.tv/api/v2/user +class PlexUserProfile { + final bool autoSelectAudio; + final int defaultAudioAccessibility; + final String? defaultAudioLanguage; + final List? defaultAudioLanguages; + final String? defaultSubtitleLanguage; + final List? defaultSubtitleLanguages; + final int autoSelectSubtitle; + final int defaultSubtitleAccessibility; + final int defaultSubtitleForced; + final int watchedIndicator; + final int mediaReviewsVisibility; + final List? mediaReviewsLanguages; + + PlexUserProfile({ + required this.autoSelectAudio, + required this.defaultAudioAccessibility, + this.defaultAudioLanguage, + this.defaultAudioLanguages, + this.defaultSubtitleLanguage, + this.defaultSubtitleLanguages, + required this.autoSelectSubtitle, + required this.defaultSubtitleAccessibility, + required this.defaultSubtitleForced, + required this.watchedIndicator, + required this.mediaReviewsVisibility, + this.mediaReviewsLanguages, + }); + + factory PlexUserProfile.fromJson(Map json) { + final profile = json['profile'] as Map? ?? json; + + return PlexUserProfile( + autoSelectAudio: profile['autoSelectAudio'] as bool? ?? true, + defaultAudioAccessibility: + profile['defaultAudioAccessibility'] as int? ?? 0, + defaultAudioLanguage: profile['defaultAudioLanguage'] as String?, + defaultAudioLanguages: profile['defaultAudioLanguages'] != null + ? List.from(profile['defaultAudioLanguages'] as List) + : null, + defaultSubtitleLanguage: profile['defaultSubtitleLanguage'] as String?, + defaultSubtitleLanguages: profile['defaultSubtitleLanguages'] != null + ? List.from(profile['defaultSubtitleLanguages'] as List) + : null, + autoSelectSubtitle: profile['autoSelectSubtitle'] as int? ?? 0, + defaultSubtitleAccessibility: + profile['defaultSubtitleAccessibility'] as int? ?? 0, + defaultSubtitleForced: profile['defaultSubtitleForced'] as int? ?? 1, + watchedIndicator: profile['watchedIndicator'] as int? ?? 1, + mediaReviewsVisibility: profile['mediaReviewsVisibility'] as int? ?? 0, + mediaReviewsLanguages: profile['mediaReviewsLanguages'] != null + ? List.from(profile['mediaReviewsLanguages'] as List) + : null, + ); + } + + Map toJson() { + return { + 'profile': { + 'autoSelectAudio': autoSelectAudio, + 'defaultAudioAccessibility': defaultAudioAccessibility, + 'defaultAudioLanguage': defaultAudioLanguage, + 'defaultAudioLanguages': defaultAudioLanguages, + 'defaultSubtitleLanguage': defaultSubtitleLanguage, + 'defaultSubtitleLanguages': defaultSubtitleLanguages, + 'autoSelectSubtitle': autoSelectSubtitle, + 'defaultSubtitleAccessibility': defaultSubtitleAccessibility, + 'defaultSubtitleForced': defaultSubtitleForced, + 'watchedIndicator': watchedIndicator, + 'mediaReviewsVisibility': mediaReviewsVisibility, + 'mediaReviewsLanguages': mediaReviewsLanguages, + }, + }; + } + + /// Returns true if subtitles should be automatically selected + bool get shouldAutoSelectSubtitle => autoSelectSubtitle > 0; + + /// Returns true if forced subtitles should be preferred + bool get preferForcedSubtitles => defaultSubtitleForced == 1; +} diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart new file mode 100644 index 00000000..b6732ee1 --- /dev/null +++ b/lib/screens/auth_screen.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../services/plex_auth_service.dart'; +import '../services/storage_service.dart'; +import 'server_selection_screen.dart'; + +class AuthScreen extends StatefulWidget { + const AuthScreen({super.key}); + + @override + State createState() => _AuthScreenState(); +} + +class _AuthScreenState extends State { + bool _isAuthenticating = false; + String? _errorMessage; + late PlexAuthService _authService; + + @override + void initState() { + super.initState(); + _initializeAuthService(); + } + + Future _initializeAuthService() async { + _authService = await PlexAuthService.create(); + } + + Future _startAuthentication() async { + setState(() { + _isAuthenticating = true; + _errorMessage = null; + }); + + try { + // Create a PIN + final pinData = await _authService.createPin(); + final pinId = pinData['id'] as int; + final pinCode = pinData['code'] as String; + + // Construct auth URL + final authUrl = _authService.getAuthUrl(pinCode); + + // Open browser (in-app for mobile, external for desktop) + final uri = Uri.parse(authUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + } else { + throw Exception('Could not launch auth URL'); + } + + // Poll for authentication + final token = await _authService.pollPinUntilClaimed(pinId); + + if (token == null) { + setState(() { + _isAuthenticating = false; + _errorMessage = 'Authentication timed out. Please try again.'; + }); + return; + } + + // Store the token + final storage = await StorageService.getInstance(); + await storage.savePlexToken(token); + + // Navigate to server selection + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => ServerSelectionScreen( + authService: _authService, + plexToken: token, + ), + ), + ); + } + } catch (e) { + setState(() { + _isAuthenticating = false; + _errorMessage = 'Authentication failed: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.video_library, + size: 80, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Plezy', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + if (_isAuthenticating) ...[ + const Center(child: CircularProgressIndicator()), + const SizedBox(height: 16), + const Text( + 'Waiting for authentication...\nPlease complete sign-in in your browser.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ] else ...[ + ElevatedButton( + onPressed: _startAuthentication, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text('Sign in with Plex'), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ], + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart new file mode 100644 index 00000000..e92be9c3 --- /dev/null +++ b/lib/screens/discover_screen.dart @@ -0,0 +1,810 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; +import '../config/plex_config.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../services/storage_service.dart'; +import '../services/plex_auth_service.dart'; +import '../widgets/media_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../widgets/server_list_tile.dart'; +import '../mixins/refreshable.dart'; +import '../utils/app_logger.dart'; +import 'video_player_screen.dart'; +import 'main_screen.dart'; + +class DiscoverScreen extends StatefulWidget { + final PlexClient client; + final PlexUserProfile? userProfile; + final VoidCallback? onBecameVisible; + + const DiscoverScreen({ + super.key, + required this.client, + this.userProfile, + this.onBecameVisible, + }); + + @override + State createState() => _DiscoverScreenState(); +} + +class _DiscoverScreenState extends State with Refreshable { + List _onDeck = []; + List _recentlyAdded = []; + bool _isLoading = true; + String? _errorMessage; + final PageController _heroController = PageController(); + int _currentHeroIndex = 0; + Timer? _autoScrollTimer; + + @override + void initState() { + super.initState(); + _loadContent(); + _startAutoScroll(); + } + + @override + void dispose() { + _autoScrollTimer?.cancel(); + _heroController.dispose(); + super.dispose(); + } + + void _startAutoScroll() { + _autoScrollTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + if (_onDeck.isEmpty || !_heroController.hasClients) return; + + final nextPage = (_currentHeroIndex + 1) % _onDeck.length; + _heroController.animateToPage( + nextPage, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + }); + } + + void _resetAutoScrollTimer() { + _autoScrollTimer?.cancel(); + _startAutoScroll(); + } + + Future _loadContent() async { + appLogger.d('Loading discover content'); + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + appLogger.d('Fetching onDeck and recentlyAdded from Plex'); + final onDeck = await widget.client.getOnDeck(); + final recentlyAdded = await widget.client.getRecentlyAdded(limit: 20); + + appLogger.d( + 'Received ${onDeck.length} on deck items and ${recentlyAdded.length} recently added items', + ); + setState(() { + _onDeck = onDeck; + _recentlyAdded = recentlyAdded; + _isLoading = false; + }); + appLogger.d('Discover content loaded successfully'); + } catch (e) { + appLogger.e('Failed to load discover content', error: e); + setState(() { + _errorMessage = 'Failed to load content: $e'; + _isLoading = false; + }); + } + } + + // Public method to refresh content + @override + void refresh() { + appLogger.d('DiscoverScreen.refresh() called'); + _loadContent(); + } + + Future _handleSwitchServer() async { + final storage = await StorageService.getInstance(); + final plexToken = storage.getPlexToken(); + + if (plexToken == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No Plex token found. Please login again.'), + ), + ); + } + return; + } + + // Show loading dialog + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading servers...'), + ], + ), + ), + ); + } + + try { + // Fetch available servers + final authService = await PlexAuthService.create(); + final servers = await authService.fetchServers(plexToken); + + // Close loading dialog + if (mounted) { + Navigator.pop(context); + } + + if (servers.isEmpty) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('No servers found'))); + } + return; + } + + // Show server selection dialog + if (mounted) { + final selectedServer = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Switch Server'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: servers.length, + itemBuilder: (context, index) { + final server = servers[index]; + return ServerListTile( + server: server, + onTap: () => Navigator.pop(context, server), + showTrailingIcon: false, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ); + + if (selectedServer != null) { + await _connectToServer(selectedServer); + } + } + } catch (e) { + // Close loading dialog if still open + if (mounted) { + Navigator.pop(context); + } + + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to load servers: $e'))); + } + } + } + + Future _connectToServer(PlexServer server) async { + // Show loading dialog + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Testing connections...'), + ], + ), + ), + ); + } + + // Test connections to find best working one + final connection = await server.findBestWorkingConnection(); + + // Close loading dialog + if (mounted) { + Navigator.pop(context); + } + + if (connection == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No working connections found for this server'), + ), + ); + } + return; + } + + // Store server information + final storage = await StorageService.getInstance(); + await storage.saveServerData(server.toJson()); + await storage.saveServerUrl(connection.uri); + await storage.saveServerAccessToken(server.accessToken); + + // Get client identifier + final clientId = storage.getClientIdentifier(); + if (clientId == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Client identifier not found')), + ); + } + return; + } + + // Create new client + final config = PlexConfig( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientId, + ); + final client = PlexClient(config); + + // Replace current screen with main screen (includes bottom nav) + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => MainScreen(client: client)), + ); + } + } + + Future _handleLogout() async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Logout'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + final storage = await StorageService.getInstance(); + await storage.clearCredentials(); + + if (mounted) { + Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + DesktopSliverAppBar( + title: const Text('Discover'), + floating: true, + pinned: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + scrolledUnderElevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadContent, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + if (value == 'switch_server') { + _handleSwitchServer(); + } else if (value == 'logout') { + _handleLogout(); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'switch_server', + child: Row( + children: [ + Icon(Icons.swap_horiz), + SizedBox(width: 8), + Text('Switch Server'), + ], + ), + ), + const PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 8), + Text('Logout'), + ], + ), + ), + ], + ), + ], + ), + if (_isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadContent, + child: const Text('Retry'), + ), + ], + ), + ), + ), + if (!_isLoading && _errorMessage == null) ...[ + // Hero Section (Continue Watching) + if (_onDeck.isNotEmpty) _buildHeroSection(), + + // On Deck / Continue Watching + if (_onDeck.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + children: [ + const Icon(Icons.play_circle_outline), + const SizedBox(width: 8), + Text( + 'Continue Watching', + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + ), + ), + _buildHorizontalList(_onDeck, isLarge: false), + ], + + // Recently Added + if (_recentlyAdded.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + children: [ + const Icon(Icons.fiber_new), + const SizedBox(width: 8), + Text( + 'Recently Added', + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + ), + ), + _buildHorizontalList(_recentlyAdded, isLarge: false), + ], + + if (_onDeck.isEmpty && _recentlyAdded.isEmpty) + const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.movie_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text('No content available'), + SizedBox(height: 8), + Text( + 'Add some media to your libraries', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ], + ), + ), + ); + } + + Widget _buildHeroSection() { + return SliverToBoxAdapter( + child: SizedBox( + height: 500, + child: Stack( + children: [ + PageView.builder( + controller: _heroController, + itemCount: _onDeck.length, + onPageChanged: (index) { + setState(() { + _currentHeroIndex = index; + }); + _resetAutoScrollTimer(); + }, + itemBuilder: (context, index) { + return _buildHeroItem(_onDeck[index]); + }, + ), + // Page indicators + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + _onDeck.length, + (index) => Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: _currentHeroIndex == index ? 24 : 8, + height: 8, + decoration: BoxDecoration( + color: _currentHeroIndex == index + ? Colors.white + : Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeroItem(PlexMetadata heroItem) { + final isEpisode = heroItem.type.toLowerCase() == 'episode'; + final showName = heroItem.grandparentTitle ?? heroItem.title; + final episodeInfo = + isEpisode && heroItem.parentIndex != null && heroItem.index != null + ? 'S${heroItem.parentIndex} · E${heroItem.index} · ${heroItem.title}' + : null; + + return GestureDetector( + onTap: () { + appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: heroItem, + userProfile: widget.userProfile, + ), + ), + ); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + fit: StackFit.expand, + children: [ + // Background Image - use episode art or grandparent art + if (heroItem.art != null || heroItem.grandparentArt != null) + CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + heroItem.art ?? heroItem.grandparentArt, + ), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ) + else + Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + + // Gradient Overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0.9), + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + ), + + // Content + Positioned( + bottom: 70, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Show logo or name/title + if (heroItem.clearLogo != null) + SizedBox( + height: 120, + width: 400, + child: CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + heroItem.clearLogo, + ), + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + placeholder: (context, url) => Align( + alignment: Alignment.centerLeft, + child: Text( + showName, + style: Theme.of(context).textTheme.displaySmall + ?.copyWith( + color: Colors.white.withValues( + alpha: 0.3, + ), + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + errorWidget: (context, url, error) { + // Fallback to text if logo fails to load + return Align( + alignment: Alignment.centerLeft, + child: Text( + showName, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), + ) + else + Text( + showName, + style: Theme.of(context).textTheme.displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + // Episode info + if (episodeInfo != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + episodeInfo, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + + // Summary + if (heroItem.summary != null) ...[ + const SizedBox(height: 12), + Text( + heroItem.summary!, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.4, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + + const SizedBox(height: 20), + + // Play Button + FilledButton.icon( + onPressed: () { + appLogger.d('Playing: ${heroItem.title}'); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: heroItem, + userProfile: widget.userProfile, + ), + ), + ); + }, + icon: const Icon(Icons.play_arrow, size: 20), + label: const Text('Play'), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHorizontalList( + List items, { + bool isLarge = false, + }) { + return SliverToBoxAdapter( + child: LayoutBuilder( + builder: (context, constraints) { + // Responsive card width based on screen size + final screenWidth = constraints.maxWidth; + final cardWidth = screenWidth > 1600 + ? 220.0 + : screenWidth > 1200 + ? 200.0 + : screenWidth > 800 + ? 160.0 + : 130.0; + + // 2:3 poster aspect ratio (height is 1.5x width) + final cardHeight = cardWidth * 1.5; + // Container height = poster + padding + spacing + text + // 8px top padding + cardHeight + 4px spacing + ~26px text + 8px bottom padding + final containerHeight = cardHeight + 46; + + return SizedBox( + height: containerHeight, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: MediaCard( + client: widget.client, + item: item, + width: cardWidth, + height: cardHeight, + onRefresh: _loadContent, + userProfile: widget.userProfile, + ), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart new file mode 100644 index 00000000..04839337 --- /dev/null +++ b/lib/screens/libraries_screen.dart @@ -0,0 +1,712 @@ +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_library.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_filter.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/media_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../services/storage_service.dart'; +import '../mixins/refreshable.dart'; + +class LibrariesScreen extends StatefulWidget { + final PlexClient client; + final PlexUserProfile? userProfile; + + const LibrariesScreen({super.key, required this.client, this.userProfile}); + + @override + State createState() => _LibrariesScreenState(); +} + +class _LibrariesScreenState extends State with Refreshable { + List _libraries = []; + List _items = []; + List _filters = []; + bool _isLoadingLibraries = true; + bool _isLoadingItems = false; + String? _errorMessage; + int _selectedLibraryIndex = 0; + Map _selectedFilters = {}; + bool _isInitialLoad = true; + + @override + void initState() { + super.initState(); + _loadLibraries(); + } + + Future _loadLibraries() async { + setState(() { + _isLoadingLibraries = true; + _errorMessage = null; + }); + + try { + final libraries = await widget.client.getLibraries(); + setState(() { + _libraries = libraries; + _isLoadingLibraries = false; + }); + + if (libraries.isNotEmpty) { + // Load saved preferences + final storage = await StorageService.getInstance(); + final savedIndex = storage.getSelectedLibraryIndex(); + final savedFilters = storage.getLibraryFilters(); + + // Use saved index if valid, otherwise default to 0 + final indexToLoad = + (savedIndex != null && savedIndex < libraries.length) + ? savedIndex + : 0; + + // Restore filters BEFORE loading content + if (savedFilters.isNotEmpty) { + _selectedFilters = Map.from(savedFilters); + } + + _loadLibraryContent(indexToLoad); + } + } catch (e) { + setState(() { + _errorMessage = 'Failed to load libraries: $e'; + _isLoadingLibraries = false; + }); + } + } + + Future _loadLibraryContent(int index) async { + if (index < 0 || index >= _libraries.length) return; + + final isChangingLibrary = !_isInitialLoad && _selectedLibraryIndex != index; + + setState(() { + _selectedLibraryIndex = index; + _isLoadingItems = true; + _errorMessage = null; + // Only clear filters when explicitly changing library (not on initial load) + if (isChangingLibrary) { + _selectedFilters.clear(); + } + }); + + // Mark that initial load is complete + if (_isInitialLoad) { + _isInitialLoad = false; + } + + // Save selected library index + final storage = await StorageService.getInstance(); + await storage.saveSelectedLibraryIndex(index); + + // Clear filters in storage when changing library + if (isChangingLibrary) { + await storage.saveLibraryFilters({}); + } + + try { + // Load filters for the new library + _loadFilters(index); + + // Load content + final items = await widget.client.getLibraryContent( + _libraries[index].key, + filters: _selectedFilters, + ); + setState(() { + _items = items; + _isLoadingItems = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load library content: $e'; + _isLoadingItems = false; + }); + } + } + + Future _loadFilters(int index) async { + if (index < 0 || index >= _libraries.length) return; + + try { + final filters = await widget.client.getLibraryFilters( + _libraries[index].key, + ); + setState(() { + _filters = filters; + }); + } catch (e) { + setState(() { + _filters = []; + }); + } + } + + Future _applyFilters() async { + setState(() { + _isLoadingItems = true; + _errorMessage = null; + }); + + try { + final items = await widget.client.getLibraryContent( + _libraries[_selectedLibraryIndex].key, + filters: _selectedFilters, + ); + setState(() { + _items = items; + _isLoadingItems = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load library content: $e'; + _isLoadingItems = false; + }); + } + } + + // Public method to refresh content + @override + void refresh() { + if (_libraries.isNotEmpty) { + _applyFilters(); + } + } + + void _showFiltersBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _FiltersBottomSheet( + filters: _filters, + selectedFilters: _selectedFilters, + client: widget.client, + onFiltersChanged: (filters) async { + setState(() { + _selectedFilters.clear(); + _selectedFilters.addAll(filters); + }); + + // Save filters to storage + final storage = await StorageService.getInstance(); + await storage.saveLibraryFilters(filters); + + _applyFilters(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + DesktopSliverAppBar( + title: const Text('Libraries'), + floating: true, + pinned: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + scrolledUnderElevation: 0, + actions: [ + if (_filters.isNotEmpty) + IconButton( + icon: Badge( + label: Text('${_selectedFilters.length}'), + isLabelVisible: _selectedFilters.isNotEmpty, + child: const Icon(Icons.filter_list), + ), + onPressed: _showFiltersBottomSheet, + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _loadLibraryContent(_selectedLibraryIndex), + ), + ], + ), + if (_isLoadingLibraries) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_errorMessage != null && _libraries.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadLibraries, + child: const Text('Retry'), + ), + ], + ), + ), + ) + else if (_libraries.isEmpty) + const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.video_library_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text('No libraries found'), + ], + ), + ), + ) + else ...[ + // Library selector chips + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(_libraries.length, (index) { + final library = _libraries[index]; + final isSelected = index == _selectedLibraryIndex; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getLibraryIcon(library.type), + size: 16, + color: isSelected + ? Theme.of( + context, + ).colorScheme.onSecondaryContainer + : Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text(library.title), + ], + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + _loadLibraryContent(index); + } + }, + ), + ); + }), + ), + ), + ), + ), + + // Content grid + if (_isLoadingItems) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => + _loadLibraryContent(_selectedLibraryIndex), + child: const Text('Retry'), + ), + ], + ), + ), + ) + else if (_items.isEmpty) + const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.folder_open, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text('This library is empty'), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 190, + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = _items[index]; + return MediaCard( + client: widget.client, + item: item, + onRefresh: _applyFilters, + userProfile: widget.userProfile, + ); + }, childCount: _items.length), + ), + ), + ], + ], + ), + ); + } + + IconData _getLibraryIcon(String type) { + switch (type.toLowerCase()) { + case 'movie': + return Icons.movie; + case 'show': + return Icons.tv; + case 'artist': + return Icons.music_note; + case 'photo': + return Icons.photo; + default: + return Icons.folder; + } + } +} + +class _FiltersBottomSheet extends StatefulWidget { + final List filters; + final Map selectedFilters; + final PlexClient client; + final Function(Map) onFiltersChanged; + + const _FiltersBottomSheet({ + required this.filters, + required this.selectedFilters, + required this.client, + required this.onFiltersChanged, + }); + + @override + State<_FiltersBottomSheet> createState() => _FiltersBottomSheetState(); +} + +class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { + PlexFilter? _currentFilter; + List _filterValues = []; + bool _isLoadingValues = false; + final Map _tempSelectedFilters = {}; + final Map _filterDisplayNames = {}; // Cache for display names + late List _sortedFilters; + + @override + void initState() { + super.initState(); + _tempSelectedFilters.addAll(widget.selectedFilters); + _sortFilters(); + } + + void _sortFilters() { + // Separate boolean filters (toggles) from regular filters + final booleanFilters = widget.filters + .where((f) => f.filterType == 'boolean') + .toList(); + final regularFilters = widget.filters + .where((f) => f.filterType != 'boolean') + .toList(); + + // Combine with boolean filters first + _sortedFilters = [...booleanFilters, ...regularFilters]; + } + + bool _isBooleanFilter(PlexFilter filter) { + return filter.filterType == 'boolean'; + } + + Future _loadFilterValues(PlexFilter filter) async { + setState(() { + _currentFilter = filter; + _isLoadingValues = true; + }); + + try { + final values = await widget.client.getFilterValues(filter.key); + setState(() { + _filterValues = values; + _isLoadingValues = false; + }); + } catch (e) { + setState(() { + _filterValues = []; + _isLoadingValues = false; + }); + } + } + + void _goBack() { + setState(() { + _currentFilter = null; + _filterValues = []; + }); + } + + void _applyFilters() { + widget.onFiltersChanged(_tempSelectedFilters); + Navigator.pop(context); + } + + String _extractFilterValue(String key, String filterName) { + if (key.contains('?')) { + final queryStart = key.indexOf('?'); + final queryString = key.substring(queryStart + 1); + final params = Uri.splitQueryString(queryString); + return params[filterName] ?? key; + } else if (key.startsWith('/')) { + return key.split('/').last; + } + return key; + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + if (_currentFilter != null) { + // Show filter options view + return Column( + children: [ + // Header with back button + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _goBack, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _currentFilter!.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // Filter options list + if (_isLoadingValues) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _filterValues.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = !_tempSelectedFilters.containsKey( + _currentFilter!.filter, + ); + return ListTile( + title: const Text('All'), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters.remove( + _currentFilter!.filter, + ); + }); + _applyFilters(); + }, + ); + } + + final value = _filterValues[index - 1]; + final filterValue = _extractFilterValue( + value.key, + _currentFilter!.filter, + ); + final isSelected = + _tempSelectedFilters[_currentFilter!.filter] == + filterValue; + + return ListTile( + title: Text(value.title), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters[_currentFilter!.filter] = + filterValue; + // Cache the display name for this filter value + _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = + value.title; + }); + _applyFilters(); + }, + ); + }, + ), + ), + ], + ); + } + + // Show main filters view + return Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + const Icon(Icons.filter_list), + const SizedBox(width: 12), + const Text( + 'Filters', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const Spacer(), + if (_tempSelectedFilters.isNotEmpty) + TextButton.icon( + onPressed: () { + setState(() { + _tempSelectedFilters.clear(); + }); + _applyFilters(); + }, + icon: const Icon(Icons.clear_all), + label: const Text('Clear All'), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // All Filters (boolean toggles first, then regular filters) + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _sortedFilters.length, + itemBuilder: (context, index) { + final filter = _sortedFilters[index]; + + // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) + if (_isBooleanFilter(filter)) { + final isActive = + _tempSelectedFilters.containsKey(filter.filter) && + _tempSelectedFilters[filter.filter] == '1'; + return SwitchListTile( + value: isActive, + onChanged: (value) { + setState(() { + if (value) { + _tempSelectedFilters[filter.filter] = '1'; + } else { + _tempSelectedFilters.remove(filter.filter); + } + }); + _applyFilters(); + }, + title: Text(filter.title), + ); + } + + // Regular navigable filters - show selected value instead of checkmark + final selectedValue = _tempSelectedFilters[filter.filter]; + String? displayValue; + if (selectedValue != null) { + // Try to get the cached display name, fall back to the value itself + displayValue = + _filterDisplayNames['${filter.filter}:$selectedValue'] ?? + selectedValue; + } + + return ListTile( + title: Text(filter.title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (displayValue != null) + Flexible( + child: Text( + displayValue, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (displayValue != null) const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => _loadFilterValues(filter), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart new file mode 100644 index 00000000..d9ee0cc4 --- /dev/null +++ b/lib/screens/main_screen.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_user_profile.dart'; +import '../utils/app_logger.dart'; +import '../main.dart'; +import '../mixins/refreshable.dart'; +import 'discover_screen.dart'; +import 'libraries_screen.dart'; +import 'search_screen.dart'; + +class MainScreen extends StatefulWidget { + final PlexClient client; + final PlexUserProfile? userProfile; + + const MainScreen({super.key, required this.client, this.userProfile}); + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State with RouteAware { + int _currentIndex = 0; + + late final List _screens; + final GlobalKey> _discoverKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + _screens = [ + DiscoverScreen( + key: _discoverKey, + client: widget.client, + userProfile: widget.userProfile, + onBecameVisible: _onDiscoverBecameVisible, + ), + LibrariesScreen(client: widget.client, userProfile: widget.userProfile), + SearchScreen(client: widget.client, userProfile: widget.userProfile), + ]; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute); + } + + @override + void dispose() { + routeObserver.unsubscribe(this); + super.dispose(); + } + + @override + void didPush() { + // Called when this route has been pushed (initial navigation) + if (_currentIndex == 0) { + _onDiscoverBecameVisible(); + } + } + + @override + void didPopNext() { + // Called when returning to this route from a child route (e.g., from video player) + if (_currentIndex == 0) { + _onDiscoverBecameVisible(); + } + } + + void _onDiscoverBecameVisible() { + appLogger.d('Navigated to home'); + // Refresh content when returning to discover page + final discoverState = _discoverKey.currentState; + if (discoverState != null && discoverState is Refreshable) { + (discoverState as Refreshable).refresh(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack(index: _currentIndex, children: _screens), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: (index) { + setState(() { + _currentIndex = index; + }); + // Notify discover screen when it becomes visible via tab switch + if (index == 0) { + _onDiscoverBecameVisible(); + } + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.video_library_outlined), + selectedIcon: Icon(Icons.video_library), + label: 'Libraries', + ), + NavigationDestination( + icon: Icon(Icons.search), + selectedIcon: Icon(Icons.search), + label: 'Search', + ), + ], + ), + ); + } +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart new file mode 100644 index 00000000..f08b259a --- /dev/null +++ b/lib/screens/media_detail_screen.dart @@ -0,0 +1,821 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../widgets/media_context_menu.dart'; +import '../utils/app_logger.dart'; +import 'season_detail_screen.dart'; +import 'video_player_screen.dart'; + +class MediaDetailScreen extends StatefulWidget { + final PlexClient client; + final PlexMetadata metadata; + final PlexUserProfile? userProfile; + + const MediaDetailScreen({ + super.key, + required this.client, + required this.metadata, + this.userProfile, + }); + + @override + State createState() => _MediaDetailScreenState(); +} + +class _MediaDetailScreenState extends State { + List _seasons = []; + bool _isLoadingSeasons = false; + PlexMetadata? _fullMetadata; + PlexMetadata? _onDeckEpisode; + bool _isLoadingMetadata = true; + + @override + void initState() { + super.initState(); + _loadFullMetadata(); + } + + Future _loadFullMetadata() async { + setState(() { + _isLoadingMetadata = true; + }); + + try { + // Fetch full metadata with clearLogo and OnDeck episode + final result = await widget.client.getMetadataWithImagesAndOnDeck( + widget.metadata.ratingKey, + ); + final metadata = result['metadata'] as PlexMetadata?; + final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; + + if (metadata != null) { + setState(() { + _fullMetadata = metadata; + _onDeckEpisode = onDeckEpisode; + _isLoadingMetadata = false; + }); + + // Load seasons if it's a show + if (metadata.type.toLowerCase() == 'show') { + _loadSeasons(); + } + return; + } + + // Fallback to passed metadata + setState(() { + _fullMetadata = widget.metadata; + _isLoadingMetadata = false; + }); + + if (widget.metadata.type.toLowerCase() == 'show') { + _loadSeasons(); + } + } catch (e) { + // Fallback to passed metadata on error + setState(() { + _fullMetadata = widget.metadata; + _isLoadingMetadata = false; + }); + + if (widget.metadata.type.toLowerCase() == 'show') { + _loadSeasons(); + } + } + } + + Future _loadSeasons() async { + setState(() { + _isLoadingSeasons = true; + }); + + try { + final seasons = await widget.client.getChildren( + widget.metadata.ratingKey, + ); + setState(() { + _seasons = seasons; + _isLoadingSeasons = false; + }); + } catch (e) { + setState(() { + _isLoadingSeasons = false; + }); + } + } + + Future _playFirstEpisode() async { + try { + // If seasons aren't loaded yet, wait for them or load them + if (_seasons.isEmpty && !_isLoadingSeasons) { + await _loadSeasons(); + } + + // Wait for seasons to finish loading if they're currently loading + while (_isLoadingSeasons) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (_seasons.isEmpty) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('No seasons found'))); + } + return; + } + + // Get the first season (usually Season 1, but could be Season 0 for specials) + final firstSeason = _seasons.first; + + // Get episodes of the first season + final episodes = await widget.client.getChildren(firstSeason.ratingKey); + + if (episodes.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No episodes found in first season')), + ); + } + return; + } + + // Play the first episode + final firstEpisode = episodes.first; + if (mounted) { + appLogger.d('Playing first episode: ${firstEpisode.title}'); + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: firstEpisode, + userProfile: widget.userProfile, + ), + ), + ); + appLogger.d('Returned from playback, refreshing metadata'); + // Refresh metadata when returning from video player + _loadFullMetadata(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading first episode: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + // Use full metadata if loaded, otherwise use passed metadata + final metadata = _fullMetadata ?? widget.metadata; + final isShow = metadata.type.toLowerCase() == 'show'; + + // Show loading state while fetching full metadata + if (_isLoadingMetadata) { + return Scaffold( + appBar: AppBar(), + body: const Center(child: CircularProgressIndicator()), + ); + } + + // Determine header height based on screen size + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 600; + final headerHeight = isDesktop ? size.height * 0.6 : size.height * 0.4; + + return Scaffold( + body: CustomScrollView( + slivers: [ + // Hero header with background art + DesktopSliverAppBar( + expandedHeight: headerHeight, + pinned: true, + leading: SafeArea( + child: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + ), + ), + ), + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + // Background Art + if (metadata.art != null) + CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl(metadata.art), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ) + else + Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + + // Gradient overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0.95), + ], + stops: const [0.3, 0.7, 1.0], + ), + ), + ), + + // Content at bottom + Positioned( + bottom: 16, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Clear logo or title + if (metadata.clearLogo != null) + SizedBox( + height: 120, + width: 400, + child: CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + metadata.clearLogo, + ), + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + placeholder: (context, url) => Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white.withValues( + alpha: 0.3, + ), + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + errorWidget: (context, url, error) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black + .withValues(alpha: 0.5), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), + ) + else + Text( + metadata.title, + style: Theme.of(context).textTheme.displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + + // Metadata chips + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (metadata.year != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${metadata.year}', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + if (metadata.contentRating != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + metadata.contentRating!, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + if (metadata.duration != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + _formatDuration(metadata.duration!), + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + + // Main content + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Action buttons + Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: FilledButton.icon( + onPressed: () async { + // For TV shows, play the OnDeck episode if available + // Otherwise, play the first episode of the first season + if (metadata.type.toLowerCase() == 'show') { + if (_onDeckEpisode != null) { + appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}'); + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: _onDeckEpisode!, + userProfile: widget.userProfile, + ), + ), + ); + appLogger.d('Returned from playback, refreshing metadata'); + // Refresh metadata when returning from video player + _loadFullMetadata(); + } else { + // No on deck episode, fetch first episode of first season + await _playFirstEpisode(); + } + } else { + appLogger.d('Playing: ${metadata.title}'); + // For movies or episodes, play directly + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: metadata, + userProfile: widget.userProfile, + ), + ), + ); + appLogger.d('Returned from playback, refreshing metadata'); + // Refresh metadata when returning from video player + _loadFullMetadata(); + } + }, + icon: const Icon(Icons.play_arrow, size: 20), + label: Text( + _getPlayButtonLabel(metadata), + style: const TextStyle(fontSize: 16), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + IconButton.filledTonal( + onPressed: () async { + try { + await widget.client.markAsWatched( + metadata.ratingKey, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Marked as watched'), + ), + ); + // Refresh metadata to update UI + _loadFullMetadata(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + }, + icon: const Icon(Icons.check), + tooltip: 'Mark as watched', + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + ), + ), + const SizedBox(width: 12), + IconButton.filledTonal( + onPressed: () async { + try { + await widget.client.markAsUnwatched( + metadata.ratingKey, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Marked as unwatched'), + ), + ); + // Refresh metadata to update UI + _loadFullMetadata(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + }, + icon: const Icon(Icons.remove_done), + tooltip: 'Mark as unwatched', + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Summary + if (metadata.summary != null) ...[ + Text( + 'Overview', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + metadata.summary!, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(height: 1.6), + ), + const SizedBox(height: 24), + ], + + // Seasons (for TV shows) + if (isShow) ...[ + Text( + 'Seasons', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + if (_isLoadingSeasons) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ) + else if (_seasons.isEmpty) + Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + 'No seasons found', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Colors.grey), + ), + ), + ) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: _seasons.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final season = _seasons[index]; + return _buildSeasonCard(season); + }, + ), + const SizedBox(height: 24), + ], + + // Additional info + if (metadata.studio != null) ...[ + _buildInfoRow('Studio', metadata.studio!), + const SizedBox(height: 12), + ], + if (metadata.contentRating != null) ...[ + _buildInfoRow('Rating', metadata.contentRating!), + const SizedBox(height: 12), + ], + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildSeasonCard(PlexMetadata season) { + return Card( + clipBehavior: Clip.antiAlias, + child: MediaContextMenu( + client: widget.client, + metadata: season, + onRefresh: _loadFullMetadata, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + SeasonDetailScreen(client: widget.client, season: season), + ), + ); + }, + child: InkWell( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Season poster + if (season.thumb != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl(season.thumb), + width: 80, + height: 120, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ), + ) + else + Container( + width: 80, + height: 120, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.movie, size: 32), + ), + const SizedBox(width: 16), + + // Season info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + season.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + if (season.leafCount != null) + Text( + '${season.leafCount} episodes', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 8), + if (season.viewedLeafCount != null && + season.leafCount != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: + season.viewedLeafCount! / season.leafCount!, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + const SizedBox(height: 4), + Text( + '${season.viewedLeafCount}/${season.leafCount} watched', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Colors.grey), + ), + ], + ), + ], + ), + ), + + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text(value, style: Theme.of(context).textTheme.bodyLarge), + ), + ], + ); + } + + String _formatDuration(int milliseconds) { + final duration = Duration(milliseconds: milliseconds); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else { + return '${minutes}m'; + } + } + + String _getPlayButtonLabel(PlexMetadata metadata) { + // For TV shows + if (metadata.type.toLowerCase() == 'show') { + if (_onDeckEpisode != null) { + final episode = _onDeckEpisode!; + final seasonNum = episode.parentIndex ?? 0; + final episodeNum = episode.index ?? 0; + + // Check if episode has been partially watched (viewOffset > 0) + if (episode.viewOffset != null && episode.viewOffset! > 0) { + return 'Resume S$seasonNum, E$episodeNum'; + } else { + return 'Play S$seasonNum, E$episodeNum'; + } + } else { + // No on deck episode, will play first episode + return 'Play S1, E1'; + } + } + + // For movies or episodes, check if partially watched + if (metadata.viewOffset != null && metadata.viewOffset! > 0) { + return 'Resume'; + } + + return 'Play'; + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart new file mode 100644 index 00000000..8e576921 --- /dev/null +++ b/lib/screens/search_screen.dart @@ -0,0 +1,212 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/media_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../mixins/refreshable.dart'; + +class SearchScreen extends StatefulWidget { + final PlexClient client; + final PlexUserProfile? userProfile; + + const SearchScreen({super.key, required this.client, this.userProfile}); + + @override + State createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State with Refreshable { + final _searchController = TextEditingController(); + List _searchResults = []; + bool _isSearching = false; + bool _hasSearched = false; + Timer? _debounceTimer; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged() { + // Cancel previous timer + _debounceTimer?.cancel(); + + final query = _searchController.text; + + if (query.trim().isEmpty) { + setState(() { + _searchResults = []; + _hasSearched = false; + _isSearching = false; + }); + return; + } + + // Start new timer + _debounceTimer = Timer(const Duration(milliseconds: 500), () { + _performSearch(query); + }); + } + + Future _performSearch(String query) async { + if (query.trim().isEmpty) { + setState(() { + _searchResults = []; + _hasSearched = false; + }); + return; + } + + setState(() { + _isSearching = true; + _hasSearched = true; + }); + + try { + final results = await widget.client.search(query); + if (mounted) { + setState(() { + _searchResults = results; + _isSearching = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isSearching = false; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Search failed: $e'))); + } + } + } + + @override + void refresh() { + // Re-run the current search if there is one + if (_searchController.text.isNotEmpty) { + _performSearch(_searchController.text); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + DesktopSliverAppBar(title: const Text('Search'), floating: true), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: SearchBar( + controller: _searchController, + hintText: 'Search movies, shows, music...', + leading: const Icon(Icons.search), + trailing: [ + if (_searchController.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + // State update handled by listener + }, + ), + ], + autoFocus: false, + ), + ), + ), + if (_isSearching) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (!_hasSearched) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search, size: 80, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Search your media', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + 'Enter a title, actor, or keyword', + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + ) + else if (_searchResults.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 80, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'No results found', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + 'Try a different search term', + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 180, + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = _searchResults[index]; + return MediaCard( + client: widget.client, + item: item, + onRefresh: refresh, + userProfile: widget.userProfile, + ); + }, childCount: _searchResults.length), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart new file mode 100644 index 00000000..6c52513a --- /dev/null +++ b/lib/screens/season_detail_screen.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/desktop_app_bar.dart'; +import 'video_player_screen.dart'; + +class SeasonDetailScreen extends StatefulWidget { + final PlexClient client; + final PlexMetadata season; + final PlexUserProfile? userProfile; + + const SeasonDetailScreen({ + super.key, + required this.client, + required this.season, + this.userProfile, + }); + + @override + State createState() => _SeasonDetailScreenState(); +} + +class _SeasonDetailScreenState extends State { + List _episodes = []; + bool _isLoadingEpisodes = false; + + @override + void initState() { + super.initState(); + _loadEpisodes(); + } + + Future _loadEpisodes() async { + setState(() { + _isLoadingEpisodes = true; + }); + + try { + final episodes = await widget.client.getChildren(widget.season.ratingKey); + setState(() { + _episodes = episodes; + _isLoadingEpisodes = false; + }); + } catch (e) { + setState(() { + _isLoadingEpisodes = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + DesktopSliverAppBar( + title: Text(widget.season.title), + pinned: true, + leading: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + ), + ), + ), + if (_isLoadingEpisodes) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_episodes.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.movie_outlined, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text( + 'No episodes found', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: Colors.grey), + ), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index.isOdd) { + return const SizedBox(height: 12); + } + final episodeIndex = index ~/ 2; + final episode = _episodes[episodeIndex]; + return _buildEpisodeCard(episode); + }, childCount: _episodes.length * 2 - 1), + ), + ), + ], + ), + ); + } + + Widget _buildEpisodeCard(PlexMetadata episode) { + final hasProgress = + episode.viewOffset != null && + episode.duration != null && + episode.viewOffset! > 0; + final progress = hasProgress + ? episode.viewOffset! / episode.duration! + : 0.0; + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: episode, + userProfile: widget.userProfile, + ), + ), + ); + // Refresh episodes when returning from video player + _loadEpisodes(); + }, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode thumbnail (16:9 aspect ratio, fixed width) + SizedBox( + width: 160, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: AspectRatio( + aspectRatio: 16 / 9, + child: episode.thumb != null + ? CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + episode.thumb, + ), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ) + : Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ), + ), + + // Play overlay + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.2), + ], + ), + ), + child: Center( + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 20, + ), + ), + ), + ), + ), + + // Watched indicator + if (episode.isWatched) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 12, + ), + ), + ), + + // Progress bar at bottom + if (hasProgress && !episode.isWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(6), + bottomRight: Radius.circular(6), + ), + child: LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.withValues(alpha: 0.3), + minHeight: 3, + ), + ), + ), + + // Duration badge + if (episode.duration != null) + Positioned( + bottom: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + _formatDuration(episode.duration!), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode number and title + Row( + children: [ + if (episode.index != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + 'E${episode.index}', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + episode.title, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + + // Summary + if (episode.summary != null && + episode.summary!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + episode.summary!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + height: 1.3, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + String _formatDuration(int milliseconds) { + final duration = Duration(milliseconds: milliseconds); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '${minutes}:${seconds.toString().padLeft(2, '0')}'; + } + } +} diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart new file mode 100644 index 00000000..1a254fb0 --- /dev/null +++ b/lib/screens/server_selection_screen.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import '../services/plex_auth_service.dart'; +import '../services/storage_service.dart'; +import '../client/plex_client.dart'; +import '../config/plex_config.dart'; +import '../widgets/server_list_tile.dart'; +import 'main_screen.dart'; + +class ServerSelectionScreen extends StatefulWidget { + final PlexAuthService authService; + final String plexToken; + + const ServerSelectionScreen({ + super.key, + required this.authService, + required this.plexToken, + }); + + @override + State createState() => _ServerSelectionScreenState(); +} + +class _ServerSelectionScreenState extends State { + List? _servers; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadServers(); + } + + Future _loadServers() async { + try { + final servers = await widget.authService.fetchServers(widget.plexToken); + setState(() { + _servers = servers; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load servers: $e'; + _isLoading = false; + }); + } + } + + Future _selectServer(PlexServer server) async { + // Show loading dialog + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Testing connections...'), + ], + ), + ), + ); + } + + // Test connections to find best working one + final connection = await server.findBestWorkingConnection(); + + // Close loading dialog + if (mounted) { + Navigator.pop(context); + } + + if (connection == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No working connections found for this server'), + ), + ); + } + return; + } + + // Store server information + final storage = await StorageService.getInstance(); + await storage.saveServerData(server.toJson()); + await storage.saveServerUrl(connection.uri); + await storage.saveServerAccessToken(server.accessToken); + await storage.savePlexToken(widget.plexToken); + + // Get client identifier + final clientId = + storage.getClientIdentifier() ?? widget.authService.clientIdentifier; + + // Create client and navigate to main app + final config = PlexConfig( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientId, + ); + final client = PlexClient(config); + + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => MainScreen(client: client)), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Select Server')), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadServers, + child: const Text('Retry'), + ), + ], + ), + ) + : _servers == null || _servers!.isEmpty + ? const Center(child: Text('No servers found')) + : ListView.builder( + itemCount: _servers!.length, + padding: const EdgeInsets.all(16), + itemBuilder: (context, index) { + final server = _servers![index]; + return Card( + child: ServerListTile( + server: server, + onTap: () => _selectServer(server), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart new file mode 100644 index 00000000..7189ae42 --- /dev/null +++ b/lib/screens/video_player_screen.dart @@ -0,0 +1,784 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../widgets/plex_video_controls.dart'; +import '../utils/language_codes.dart'; +import '../utils/app_logger.dart'; + +class VideoPlayerScreen extends StatefulWidget { + final PlexClient client; + final PlexMetadata metadata; + final AudioTrack? preferredAudioTrack; + final SubtitleTrack? preferredSubtitleTrack; + final PlexUserProfile? userProfile; + + const VideoPlayerScreen({ + super.key, + required this.client, + required this.metadata, + this.preferredAudioTrack, + this.preferredSubtitleTrack, + this.userProfile, + }); + + @override + State createState() => _VideoPlayerScreenState(); +} + +class _VideoPlayerScreenState extends State { + late final Player player; + late final VideoController controller; + Timer? _progressTimer; + PlexMetadata? _nextEpisode; + PlexMetadata? _previousEpisode; + bool _isLoadingNext = false; + bool _showPlayNextDialog = false; + + @override + void initState() { + super.initState(); + + appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}'); + if (widget.userProfile != null) { + appLogger.d('Using user profile for track selection'); + } + if (widget.preferredAudioTrack != null) { + appLogger.d( + 'Preferred audio track: ${widget.preferredAudioTrack!.title ?? widget.preferredAudioTrack!.id} (${widget.preferredAudioTrack!.language ?? "unknown"})', + ); + } + if (widget.preferredSubtitleTrack != null) { + final subtitleDesc = widget.preferredSubtitleTrack!.id == "no" + ? "OFF" + : "${widget.preferredSubtitleTrack!.title ?? widget.preferredSubtitleTrack!.id} (${widget.preferredSubtitleTrack!.language ?? "unknown"})"; + appLogger.d('Preferred subtitle track: $subtitleDesc'); + } + + // Create player and controller + player = Player(configuration: PlayerConfiguration(libass: true)); + controller = VideoController(player); + + // Get the video URL and start playback + _startPlayback(); + + // Set fullscreen mode and landscape orientation + _setLandscapeOrientation(); + + // Listen to playback state changes + player.stream.playing.listen(_onPlayingStateChanged); + + // Listen to completion + player.stream.completed.listen(_onVideoCompleted); + + // Start periodic progress updates + _startProgressTracking(); + + // Load next/previous episodes + _loadAdjacentEpisodes(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Ensure landscape orientation is set even after navigation + _setLandscapeOrientation(); + } + + void _setLandscapeOrientation() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + + Future _loadAdjacentEpisodes() async { + if (widget.metadata.type.toLowerCase() != 'episode') { + return; + } + + try { + final next = await widget.client.getNextEpisode(widget.metadata); + final previous = await widget.client.getPreviousEpisode(widget.metadata); + + if (mounted) { + setState(() { + _nextEpisode = next; + _previousEpisode = previous; + }); + } + } catch (e) { + // Silently handle errors + } + } + + Future _startPlayback() async { + try { + // Get the direct file URL from the server + final videoUrl = await widget.client.getVideoUrl( + widget.metadata.ratingKey, + ); + + if (videoUrl != null) { + // Open video without auto-playing + await player.open(Media(videoUrl), play: false); + + // Wait for media to be ready (duration > 0) + int attempts = 0; + while (player.state.duration.inMilliseconds == 0 && attempts < 50) { + await Future.delayed(const Duration(milliseconds: 100)); + attempts++; + } + + // Set up playback position if resuming + if (widget.metadata.viewOffset != null && + widget.metadata.viewOffset! > 0) { + final resumePosition = Duration( + milliseconds: widget.metadata.viewOffset!, + ); + await player.seek(resumePosition); + } + + // Start playback after seeking + await player.play(); + + // Wait for tracks to be loaded, then apply preferred tracks + _waitForTracksAndApply(); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not find video file')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + @override + void dispose() { + // Stop progress tracking + _progressTimer?.cancel(); + + // Send final stopped state + _sendProgress('stopped'); + + // Restore system UI + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + // Restore portrait-only orientation + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + player.dispose(); + super.dispose(); + } + + void _startProgressTracking() { + // Send progress update every 10 seconds + _progressTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (player.state.playing) { + _sendProgress('playing'); + } + }); + } + + AudioTrack? _findBestAudioMatch( + List availableTracks, + AudioTrack preferred, + ) { + if (availableTracks.isEmpty) return null; + + // Filter out auto and no tracks + final validTracks = availableTracks + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + if (validTracks.isEmpty) return null; + + // Try to match: index, title, and language + for (var track in validTracks) { + if (track.id == preferred.id && + track.title == preferred.title && + track.language == preferred.language) { + return track; + } + } + + // Try to match: title and language + for (var track in validTracks) { + if (track.title == preferred.title && + track.language == preferred.language) { + return track; + } + } + + // Try to match: language only + for (var track in validTracks) { + if (track.language == preferred.language) { + return track; + } + } + + return null; + } + + AudioTrack? _findAudioTrackByProfile( + List availableTracks, + PlexUserProfile profile, + ) { + appLogger.d('Audio track selection using user profile'); + appLogger.d( + 'Profile settings - autoSelectAudio: ${profile.autoSelectAudio}, defaultAudioLanguage: ${profile.defaultAudioLanguage}', + ); + + if (availableTracks.isEmpty || !profile.autoSelectAudio) { + appLogger.d( + 'Cannot use profile: ${availableTracks.isEmpty ? "No tracks available" : "autoSelectAudio is false"}', + ); + return null; + } + + final preferredLanguage = profile.defaultAudioLanguage; + if (preferredLanguage == null || preferredLanguage.isEmpty) { + appLogger.d('Cannot use profile: No defaultAudioLanguage specified'); + return null; + } + + // Get all possible language code variations (e.g., "en" → ["en", "eng"]) + final languageVariations = LanguageCodes.getVariations(preferredLanguage); + appLogger.d( + 'Checking language variations: ${languageVariations.join(", ")}', + ); + + // Try to find track matching any language variation + for (var track in availableTracks) { + final trackLang = track.language?.toLowerCase(); + if (trackLang != null && languageVariations.contains(trackLang)) { + appLogger.d( + 'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + ); + return track; + } + } + + appLogger.d( + 'No audio track found matching profile language "$preferredLanguage" or its variations', + ); + return null; + } + + SubtitleTrack? _findBestSubtitleMatch( + List availableTracks, + SubtitleTrack preferred, + ) { + // If preferred is "no", return no subtitles + if (preferred.id == 'no') { + return SubtitleTrack.no(); + } + + if (availableTracks.isEmpty) return null; + + // Filter out auto and no tracks + final validTracks = availableTracks + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + if (validTracks.isEmpty) return null; + + // Try to match: index, title, and language + for (var track in validTracks) { + if (track.id == preferred.id && + track.title == preferred.title && + track.language == preferred.language) { + return track; + } + } + + // Try to match: title and language + for (var track in validTracks) { + if (track.title == preferred.title && + track.language == preferred.language) { + return track; + } + } + + // Try to match: language only + for (var track in validTracks) { + if (track.language == preferred.language) { + return track; + } + } + + return null; + } + + SubtitleTrack? _findSubtitleTrackByProfile( + List availableTracks, + PlexUserProfile profile, + ) { + appLogger.d('Subtitle track selection using user profile'); + appLogger.d( + 'Profile settings - autoSelectSubtitle: ${profile.autoSelectSubtitle}, defaultSubtitleLanguage: ${profile.defaultSubtitleLanguage}, defaultSubtitleForced: ${profile.defaultSubtitleForced}', + ); + + if (availableTracks.isEmpty) { + appLogger.d('Cannot use profile: No subtitle tracks available'); + return null; + } + + // If autoSelectSubtitle is 0, don't select any subtitle + if (!profile.shouldAutoSelectSubtitle) { + appLogger.d( + 'Profile specifies no auto-select (autoSelectSubtitle=0) - Subtitles OFF', + ); + return SubtitleTrack.no(); + } + + final preferredLanguage = profile.defaultSubtitleLanguage; + if (preferredLanguage == null || preferredLanguage.isEmpty) { + appLogger.d('Cannot use profile: No defaultSubtitleLanguage specified'); + return null; + } + + // Get all possible language code variations (e.g., "en" → ["en", "eng"]) + final languageVariations = LanguageCodes.getVariations(preferredLanguage); + appLogger.d( + 'Checking language variations: ${languageVariations.join(", ")}', + ); + + // If defaultSubtitleForced is 1, prefer forced subtitles + if (profile.preferForcedSubtitles) { + appLogger.d('Profile prefers forced subtitles (defaultSubtitleForced=1)'); + // Try to find forced subtitle in preferred language + for (var track in availableTracks) { + final trackLang = track.language?.toLowerCase(); + if (trackLang != null && + languageVariations.contains(trackLang) && + track.title?.toLowerCase().contains('forced') == true) { + appLogger.d( + 'Found forced subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + ); + return track; + } + } + appLogger.d( + 'No forced subtitle found in "$preferredLanguage" or its variations, trying regular subtitles', + ); + } + + // Try to find regular subtitle in preferred language + for (var track in availableTracks) { + final trackLang = track.language?.toLowerCase(); + if (trackLang != null && languageVariations.contains(trackLang)) { + appLogger.d( + 'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + ); + return track; + } + } + + appLogger.d( + 'No subtitle track found matching profile language "$preferredLanguage" or its variations', + ); + return null; + } + + void _waitForTracksAndApply() async { + // Helper function to process tracks + Future processTracks(Tracks tracks) async { + appLogger.d('Starting track selection process'); + + // Get real tracks (excluding auto and no) + final realAudioTracks = tracks.audio + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + final realSubtitleTracks = tracks.subtitle + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + + appLogger.d('Available audio tracks: ${realAudioTracks.length}'); + for (var track in realAudioTracks) { + appLogger.d( + ' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}', + ); + } + appLogger.d('Available subtitle tracks: ${realSubtitleTracks.length}'); + for (var track in realSubtitleTracks) { + appLogger.d( + ' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}', + ); + } + + // Select audio track with priority: preferred > user profile > default > first + appLogger.d('Audio track selection'); + if (realAudioTracks.isNotEmpty) { + AudioTrack? trackToSelect; + + // Priority 1: Try to match preferred track from navigation + if (widget.preferredAudioTrack != null) { + appLogger.d('Priority 1: Checking preferred track from navigation'); + appLogger.d( + ' Preferred: ${widget.preferredAudioTrack!.title ?? "Track ${widget.preferredAudioTrack!.id}"} (${widget.preferredAudioTrack!.language ?? "unknown"})', + ); + trackToSelect = _findBestAudioMatch( + realAudioTracks, + widget.preferredAudioTrack!, + ); + if (trackToSelect != null) { + appLogger.d(' Matched preferred track'); + } else { + appLogger.d(' No match found for preferred track'); + } + } else { + appLogger.d('Priority 1: No preferred track from navigation'); + } + + // Priority 2: If no preferred track matched, try user profile preferences + if (trackToSelect == null && widget.userProfile != null) { + appLogger.d('Priority 2: Checking user profile preferences'); + trackToSelect = _findAudioTrackByProfile( + realAudioTracks, + widget.userProfile!, + ); + } else if (trackToSelect == null) { + appLogger.d('Priority 2: No user profile available'); + } + + // Priority 3: If no match, use default or first track + if (trackToSelect == null) { + appLogger.d('Priority 3: Using default or first available track'); + trackToSelect = realAudioTracks.firstWhere( + (t) => t.isDefault == true, + orElse: () => realAudioTracks.first, + ); + final isDefault = trackToSelect.isDefault == true; + appLogger.d( + ' Selected ${isDefault ? "default" : "first"} track: ${trackToSelect.title ?? "Track ${trackToSelect.id}"} (${trackToSelect.language ?? "unknown"})', + ); + } + + appLogger.i( + 'Final audio selection: ${trackToSelect.title ?? "Track ${trackToSelect.id}"} (${trackToSelect.language ?? "unknown"})', + ); + player.setAudioTrack(trackToSelect); + } else { + appLogger.d('No audio tracks available'); + } + + // Select subtitle track with priority: preferred > user profile > default > off + appLogger.d('Subtitle track selection'); + SubtitleTrack? subtitleToSelect; + + // Priority 1: Try preferred track from navigation (always wins) + if (widget.preferredSubtitleTrack != null) { + appLogger.d('Priority 1: Checking preferred track from navigation'); + if (widget.preferredSubtitleTrack!.id == 'no') { + appLogger.d(' Preferred: OFF'); + subtitleToSelect = SubtitleTrack.no(); + appLogger.d(' Using preferred setting: Subtitles OFF'); + } else if (realSubtitleTracks.isNotEmpty) { + appLogger.d( + ' Preferred: ${widget.preferredSubtitleTrack!.title ?? "Track ${widget.preferredSubtitleTrack!.id}"} (${widget.preferredSubtitleTrack!.language ?? "unknown"})', + ); + subtitleToSelect = _findBestSubtitleMatch( + realSubtitleTracks, + widget.preferredSubtitleTrack!, + ); + if (subtitleToSelect != null) { + appLogger.d(' Matched preferred track'); + } else { + appLogger.d(' No match found for preferred track'); + } + } + } else { + appLogger.d('Priority 1: No preferred track from navigation'); + } + + // Priority 2: If no preferred match, apply user profile preferences + if (subtitleToSelect == null && + widget.userProfile != null && + realSubtitleTracks.isNotEmpty) { + appLogger.d('Priority 2: Checking user profile preferences'); + subtitleToSelect = _findSubtitleTrackByProfile( + realSubtitleTracks, + widget.userProfile!, + ); + } else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { + appLogger.d('Priority 2: No user profile available'); + } + + // Priority 3: If no profile match, check for default subtitle + if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { + appLogger.d('Priority 3: Checking for default subtitle track'); + final defaultTrackIndex = realSubtitleTracks.indexWhere( + (t) => t.isDefault == true, + ); + if (defaultTrackIndex != -1) { + subtitleToSelect = realSubtitleTracks[defaultTrackIndex]; + appLogger.d( + ' Found default track: ${subtitleToSelect.title ?? "Track ${subtitleToSelect.id}"} (${subtitleToSelect.language ?? "unknown"})', + ); + } else { + appLogger.d(' No default subtitle track found'); + } + } + + // If still no subtitle selected, turn off + if (subtitleToSelect == null) { + appLogger.d('Priority 4: No subtitle selected - Subtitles OFF'); + subtitleToSelect = SubtitleTrack.no(); + } + + final finalSubtitle = subtitleToSelect.id == 'no' + ? 'OFF' + : '${subtitleToSelect.title ?? "Track ${subtitleToSelect.id}"} (${subtitleToSelect.language ?? "unknown"})'; + appLogger.i('Final subtitle selection: $finalSubtitle'); + player.setSubtitleTrack(subtitleToSelect); + + appLogger.d('Track selection complete'); + } + + // Check if tracks are already available in current state + final currentTracks = player.state.tracks; + if (currentTracks.audio.isNotEmpty || currentTracks.subtitle.isNotEmpty) { + await processTracks(currentTracks); + return; + } + + // If not, listen to tracks stream for when they become available + bool applied = false; + final subscription = player.stream.tracks.listen((tracks) async { + // Check if tracks are loaded (have at least one track) and not yet applied + if (!applied && (tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty)) { + applied = true; + await processTracks(tracks); + } + }); + + // Cancel subscription after timeout + Future.delayed(const Duration(seconds: 5), () { + subscription.cancel(); + }); + } + + void _onPlayingStateChanged(bool isPlaying) { + // Send timeline update when playback state changes + _sendProgress(isPlaying ? 'playing' : 'paused'); + } + + void _sendProgress(String state) { + final position = player.state.position.inMilliseconds; + final duration = player.state.duration.inMilliseconds; + + if (duration > 0) { + widget.client + .updateProgress( + widget.metadata.ratingKey, + time: position, + state: state, + duration: duration, + ) + .catchError((error) { + // Silently handle errors - don't interrupt playback + }); + } + } + + void _onVideoCompleted(bool completed) { + if (completed && _nextEpisode != null && !_showPlayNextDialog) { + setState(() { + _showPlayNextDialog = true; + }); + } + } + + Future _playNext() async { + if (_nextEpisode == null || _isLoadingNext) return; + + setState(() { + _isLoadingNext = true; + _showPlayNextDialog = false; + }); + + // Capture current track selection BEFORE pausing + final currentAudioTrack = player.state.track.audio; + final currentSubtitleTrack = player.state.track.subtitle; + + // Pause and stop current playback + player.pause(); + _progressTimer?.cancel(); + _sendProgress('stopped'); + + // Navigate to the next episode using pushReplacement to destroy current player + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: _nextEpisode!, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: currentSubtitleTrack, + userProfile: widget.userProfile, + ), + ), + ); + } + } + + Future _playPrevious() async { + if (_previousEpisode == null) return; + + // Capture current track selection BEFORE pausing + final currentAudioTrack = player.state.track.audio; + final currentSubtitleTrack = player.state.track.subtitle; + + // Pause and stop current playback + player.pause(); + _progressTimer?.cancel(); + _sendProgress('stopped'); + + // Navigate to the previous episode using pushReplacement to destroy current player + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: _previousEpisode!, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: currentSubtitleTrack, + userProfile: widget.userProfile, + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: true, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // Video player + Center( + child: Video( + controller: controller, + controls: (state) => plexVideoControlsBuilder( + player, + widget.client, + widget.metadata, + onNext: _nextEpisode != null ? _playNext : null, + onPrevious: _previousEpisode != null ? _playPrevious : null, + ), + ), + ), + // Play Next Dialog + if (_showPlayNextDialog && _nextEpisode != null) + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.8), + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 32), + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_circle_outline, + size: 64, + color: Colors.white, + ), + const SizedBox(height: 24), + const Text( + 'Up Next', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + _nextEpisode!.grandparentTitle ?? + _nextEpisode!.title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + if (_nextEpisode!.parentIndex != null && + _nextEpisode!.index != null) + Text( + 'S${_nextEpisode!.parentIndex} · E${_nextEpisode!.index} · ${_nextEpisode!.title}', + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton( + onPressed: () { + setState(() { + _showPlayNextDialog = false; + }); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + child: const Text('Cancel'), + ), + const SizedBox(width: 16), + FilledButton( + onPressed: _playNext, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + child: const Text('Play Now'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/fullscreen_state_manager.dart b/lib/services/fullscreen_state_manager.dart new file mode 100644 index 00000000..77533cc4 --- /dev/null +++ b/lib/services/fullscreen_state_manager.dart @@ -0,0 +1,67 @@ +import 'dart:io' show Platform; +import 'package:flutter/foundation.dart'; +import 'package:window_manager/window_manager.dart'; + +/// Global manager for tracking fullscreen state across the app +class FullscreenStateManager extends ChangeNotifier with WindowListener { + static final FullscreenStateManager _instance = + FullscreenStateManager._internal(); + + factory FullscreenStateManager() => _instance; + + FullscreenStateManager._internal(); + + bool _isFullscreen = false; + bool _isListening = false; + + bool get isFullscreen => _isFullscreen; + + /// Manually set fullscreen state (called by NSWindowDelegate callbacks on macOS) + void setFullscreen(bool value) { + if (_isFullscreen != value) { + _isFullscreen = value; + notifyListeners(); + } + } + + /// Start monitoring fullscreen state + void startMonitoring() { + if (!_shouldMonitor() || _isListening) return; + + // Use window_manager listener for Windows/Linux + // macOS uses NSWindowDelegate callbacks instead (see FullscreenWindowDelegate) + if (!Platform.isMacOS) { + windowManager.addListener(this); + _isListening = true; + } + } + + /// Stop monitoring fullscreen state + void stopMonitoring() { + if (_isListening) { + windowManager.removeListener(this); + _isListening = false; + } + } + + bool _shouldMonitor() { + return Platform.isMacOS || Platform.isWindows || Platform.isLinux; + } + + // WindowListener callbacks for Windows/Linux + @override + void onWindowEnterFullScreen() { + setFullscreen(true); + } + + @override + void onWindowLeaveFullScreen() { + setFullscreen(false); + } + + @override + void dispose() { + stopMonitoring(); + super.dispose(); + } +} diff --git a/lib/services/fullscreen_window_delegate.dart b/lib/services/fullscreen_window_delegate.dart new file mode 100644 index 00000000..cb2fcee9 --- /dev/null +++ b/lib/services/fullscreen_window_delegate.dart @@ -0,0 +1,70 @@ +import 'package:macos_window_utils/macos_window_utils.dart'; +import 'package:macos_window_utils/macos/ns_window_delegate.dart'; +import 'package:macos_window_utils/macos/ns_window_button_type.dart'; +import 'package:flutter/material.dart' show Offset; +import 'fullscreen_state_manager.dart'; + +/// Custom window delegate that manages titlebar configuration during fullscreen transitions +class FullscreenWindowDelegate extends NSWindowDelegate { + static const double _customButtonY = 21.0; + + @override + void windowWillEnterFullScreen() { + // Notify global state manager + FullscreenStateManager().setFullscreen(true); + + // Remove toolbar and restore default titlebar before entering fullscreen + _prepareForFullscreen(); + } + + @override + void windowWillExitFullScreen() { + // Hide title and make transparent immediately (safe to do before transition) + WindowManipulator.hideTitle(); + WindowManipulator.makeTitlebarTransparent(); + } + + @override + void windowDidExitFullScreen() { + // Notify global state manager + FullscreenStateManager().setFullscreen(false); + + // Add toolbar and reposition traffic lights after transition completes + WindowManipulator.addToolbar(); + + // Restore custom traffic light positions + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.closeButton, + offset: const Offset(20, _customButtonY), + ); + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.miniaturizeButton, + offset: const Offset(40, _customButtonY), + ); + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.zoomButton, + offset: const Offset(60, _customButtonY), + ); + } + + /// Prepare titlebar for fullscreen mode + void _prepareForFullscreen() { + WindowManipulator.removeToolbar(); + WindowManipulator.showTitle(); + WindowManipulator.makeTitlebarOpaque(); + + // Set traffic lights to standard fullscreen positions (null = default) + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.closeButton, + offset: null, + ); + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.miniaturizeButton, + offset: null, + ); + WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.zoomButton, + offset: null, + ); + } +} diff --git a/lib/services/macos_titlebar_service.dart b/lib/services/macos_titlebar_service.dart new file mode 100644 index 00000000..3ee66cfa --- /dev/null +++ b/lib/services/macos_titlebar_service.dart @@ -0,0 +1,61 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.dart' show Offset; +import 'package:macos_window_utils/macos_window_utils.dart'; +import 'package:macos_window_utils/macos/ns_window_button_type.dart'; +import 'fullscreen_window_delegate.dart'; + +/// Service to manage macOS titlebar configuration +class MacOSTitlebarService { + // Standard button Y position when using custom toolbar + static const double _customButtonY = 21.0; + + /// Initialize the custom titlebar setup (transparent with toolbar) + /// This configuration automatically handles fullscreen mode natively + static Future setupCustomTitlebar() async { + if (!Platform.isMacOS) return; + + // Enable window delegate to use presentation options and fullscreen callbacks + await WindowManipulator.initialize(enableWindowDelegate: true); + + // Register custom delegate to handle fullscreen transitions + final delegate = FullscreenWindowDelegate(); + WindowManipulator.addNSWindowDelegate(delegate); + + // Make titlebar transparent but keep it functional + await WindowManipulator.makeTitlebarTransparent(); + await WindowManipulator.hideTitle(); + await WindowManipulator.enableFullSizeContentView(); + + // Add toolbar to create space for traffic lights in normal mode + await WindowManipulator.addToolbar(); + + // Set custom traffic light positions for normal mode + await _setCustomButtonPositions(); + + // Configure fullscreen presentation to auto-hide toolbar and menubar + // This tells macOS to automatically hide the toolbar when entering fullscreen + final presentationOptions = NSAppPresentationOptions.from({ + NSAppPresentationOption.fullScreen, + NSAppPresentationOption.autoHideToolbar, + NSAppPresentationOption.autoHideMenuBar, + NSAppPresentationOption.autoHideDock, + }); + presentationOptions.applyAsFullScreenPresentationOptions(); + } + + /// Set traffic light buttons to custom positions (with toolbar offset) + static Future _setCustomButtonPositions() async { + await WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.closeButton, + offset: const Offset(20, _customButtonY), + ); + await WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.miniaturizeButton, + offset: const Offset(40, _customButtonY), + ); + await WindowManipulator.overrideStandardWindowButtonPosition( + buttonType: NSWindowButtonType.zoomButton, + offset: const Offset(60, _customButtonY), + ); + } +} diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart new file mode 100644 index 00000000..ca3f65f8 --- /dev/null +++ b/lib/services/plex_auth_service.dart @@ -0,0 +1,370 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:uuid/uuid.dart'; +import 'storage_service.dart'; +import '../client/plex_client.dart'; +import '../models/plex_user_profile.dart'; + +class PlexAuthService { + static const String _appName = 'Plezy'; + static const String _plexApiBase = 'https://plex.tv/api/v2'; + static const String _clientsApi = 'https://clients.plex.tv/api/v2'; + + final Dio _dio; + late final String _clientIdentifier; + + PlexAuthService._(this._dio, this._clientIdentifier); + + static Future create() async { + final storage = await StorageService.getInstance(); + final dio = Dio(); + + // Get or create client identifier + String? clientId = storage.getClientIdentifier(); + if (clientId == null) { + clientId = const Uuid().v4(); + await storage.saveClientIdentifier(clientId); + } + + return PlexAuthService._(dio, clientId); + } + + String get clientIdentifier => _clientIdentifier; + + /// Verify if a plex.tv token is valid + Future verifyToken(String token) async { + try { + final response = await _dio.get( + '$_plexApiBase/user', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Token': token, + }, + validateStatus: (status) => status != null && status < 500, + ), + ); + return response.statusCode == 200; + } catch (e) { + return false; + } + } + + /// Create a PIN for authentication + Future> createPin() async { + final response = await _dio.post( + '$_plexApiBase/pins?strong=true', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + }, + ), + ); + + return response.data as Map; + } + + /// Construct the Auth App URL for the user to visit + String getAuthUrl(String pinCode) { + final params = { + 'clientID': _clientIdentifier, + 'code': pinCode, + 'context[device][product]': _appName, + }; + + final queryString = params.entries + .map( + (e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', + ) + .join('&'); + + return 'https://app.plex.tv/auth#?$queryString'; + } + + /// Poll the PIN to check if it has been claimed + Future checkPin(int pinId) async { + try { + final response = await _dio.get( + '$_plexApiBase/pins/$pinId', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Client-Identifier': _clientIdentifier, + }, + ), + ); + + final data = response.data as Map; + return data['authToken'] as String?; + } catch (e) { + return null; + } + } + + /// Poll the PIN until it's claimed or timeout + Future pollPinUntilClaimed( + int pinId, { + Duration timeout = const Duration(minutes: 5), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + final token = await checkPin(pinId); + if (token != null) { + return token; + } + + // Wait 1 second before polling again + await Future.delayed(const Duration(seconds: 1)); + } + + return null; // Timeout + } + + /// Fetch available Plex servers for the authenticated user + Future> fetchServers(String plexToken) async { + final response = await _dio.get( + '$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Token': plexToken, + }, + ), + ); + + final List resources = response.data as List; + + // Filter for server resources and map to PlexServer objects + return resources + .where((r) => r['provides'] == 'server') + .map((r) => PlexServer.fromJson(r as Map)) + .toList(); + } + + /// Get user information + Future> getUserInfo(String token) async { + final response = await _dio.get( + '$_plexApiBase/user', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Token': token, + }, + ), + ); + + return response.data as Map; + } + + /// Get user profile with preferences (audio/subtitle settings) + Future getUserProfile(String token) async { + final response = await _dio.get( + '$_clientsApi/user', + options: Options( + headers: { + 'Accept': 'application/json', + 'X-Plex-Product': _appName, + 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Token': token, + }, + ), + ); + + return PlexUserProfile.fromJson(response.data as Map); + } +} + +/// Represents a Plex Media Server +class PlexServer { + final String name; + final String clientIdentifier; + final String accessToken; + final List connections; + final bool owned; + final String? product; + final String? platform; + final DateTime? lastSeenAt; + final bool presence; + + PlexServer({ + required this.name, + required this.clientIdentifier, + required this.accessToken, + required this.connections, + required this.owned, + this.product, + this.platform, + this.lastSeenAt, + this.presence = false, + }); + + factory PlexServer.fromJson(Map json) { + final List connectionsJson = json['connections'] as List; + final connections = connectionsJson + .map((c) => PlexConnection.fromJson(c as Map)) + .toList(); + + DateTime? lastSeenAt; + if (json['lastSeenAt'] != null) { + try { + lastSeenAt = DateTime.parse(json['lastSeenAt'] as String); + } catch (e) { + lastSeenAt = null; + } + } + + return PlexServer( + name: json['name'] as String, + clientIdentifier: json['clientIdentifier'] as String, + accessToken: json['accessToken'] as String, + connections: connections, + owned: json['owned'] as bool? ?? false, + product: json['product'] as String?, + platform: json['platform'] as String?, + lastSeenAt: lastSeenAt, + presence: json['presence'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'name': name, + 'clientIdentifier': clientIdentifier, + 'accessToken': accessToken, + 'connections': connections.map((c) => c.toJson()).toList(), + 'owned': owned, + 'product': product, + 'platform': platform, + 'lastSeenAt': lastSeenAt?.toIso8601String(), + 'presence': presence, + }; + } + + /// Check if server is online using the presence field + bool get isOnline => presence; + + /// Get the best connection URL + /// Priority: local > remote > relay + PlexConnection? getBestConnection() { + if (connections.isEmpty) return null; + + // Try to find local connection first + final local = connections.where((c) => c.local && !c.relay).toList(); + if (local.isNotEmpty) return local.first; + + // Try remote (non-relay) connection + final remote = connections.where((c) => !c.local && !c.relay).toList(); + if (remote.isNotEmpty) return remote.first; + + // Fall back to relay as last resort + final relay = connections.where((c) => c.relay).toList(); + if (relay.isNotEmpty) return relay.first; + + // Return any connection + return connections.first; + } + + /// Find the best working connection by testing them + /// Tests ALL connections simultaneously and returns the best working one + /// Priority: local > remote > relay (from successful connections) + Future findBestWorkingConnection() async { + if (connections.isEmpty) return null; + + // Test all connections simultaneously + final results = await Future.wait( + connections.map((connection) async { + final works = await PlexClient.testConnectionUrl( + connection.uri, + accessToken, + ); + return works ? connection : null; + }), + ); + + // Filter out failed connections + final workingConnections = results + .where((c) => c != null) + .cast() + .toList(); + + if (workingConnections.isEmpty) return null; + + // From working connections, prefer local > remote > relay + final localWorking = workingConnections + .where((c) => c.local && !c.relay) + .toList(); + if (localWorking.isNotEmpty) return localWorking.first; + + final remoteWorking = workingConnections + .where((c) => !c.local && !c.relay) + .toList(); + if (remoteWorking.isNotEmpty) return remoteWorking.first; + + final relayWorking = workingConnections.where((c) => c.relay).toList(); + if (relayWorking.isNotEmpty) return relayWorking.first; + + // Fallback to any working connection + return workingConnections.first; + } +} + +/// Represents a connection to a Plex server +class PlexConnection { + final String protocol; + final String address; + final int port; + final String uri; + final bool local; + final bool relay; + final bool ipv6; + + PlexConnection({ + required this.protocol, + required this.address, + required this.port, + required this.uri, + required this.local, + required this.relay, + required this.ipv6, + }); + + factory PlexConnection.fromJson(Map json) { + return PlexConnection( + protocol: json['protocol'] as String, + address: json['address'] as String, + port: json['port'] as int, + uri: json['uri'] as String, + local: json['local'] as bool? ?? false, + relay: json['relay'] as bool? ?? false, + ipv6: json['IPv6'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'protocol': protocol, + 'address': address, + 'port': port, + 'uri': uri, + 'local': local, + 'relay': relay, + 'IPv6': ipv6, + }; + } + + String get displayType { + if (relay) return 'Relay'; + if (local) return 'Local'; + return 'Remote'; + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart new file mode 100644 index 00000000..a863ac63 --- /dev/null +++ b/lib/services/storage_service.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +class StorageService { + static const String _keyServerUrl = 'server_url'; + static const String _keyToken = 'token'; + static const String _keyPlexToken = 'plex_token'; + static const String _keyServerData = 'server_data'; + static const String _keyClientId = 'client_identifier'; + static const String _keySelectedLibraryIndex = 'selected_library_index'; + static const String _keyLibraryFilters = 'library_filters'; + static const String _keyUserProfile = 'user_profile'; + + static StorageService? _instance; + late SharedPreferences _prefs; + + StorageService._(); + + static Future getInstance() async { + if (_instance == null) { + _instance = StorageService._(); + await _instance!._init(); + } + return _instance!; + } + + Future _init() async { + _prefs = await SharedPreferences.getInstance(); + } + + // Server URL + Future saveServerUrl(String url) async { + await _prefs.setString(_keyServerUrl, url); + } + + String? getServerUrl() { + return _prefs.getString(_keyServerUrl); + } + + // Server Access Token + Future saveToken(String token) async { + await _prefs.setString(_keyToken, token); + } + + String? getToken() { + return _prefs.getString(_keyToken); + } + + // Alias for server access token for clarity + Future saveServerAccessToken(String token) async { + await saveToken(token); + } + + String? getServerAccessToken() { + return getToken(); + } + + // Plex.tv Token (for API access) + Future savePlexToken(String token) async { + await _prefs.setString(_keyPlexToken, token); + } + + String? getPlexToken() { + return _prefs.getString(_keyPlexToken); + } + + // Server Data (full PlexServer object as JSON) + Future saveServerData(Map serverJson) async { + final jsonString = json.encode(serverJson); + await _prefs.setString(_keyServerData, jsonString); + } + + Map? getServerData() { + final jsonString = _prefs.getString(_keyServerData); + if (jsonString == null) return null; + + try { + return json.decode(jsonString) as Map; + } catch (e) { + return null; + } + } + + // Client Identifier + Future saveClientIdentifier(String clientId) async { + await _prefs.setString(_keyClientId, clientId); + } + + String? getClientIdentifier() { + return _prefs.getString(_keyClientId); + } + + // Save all credentials at once + Future saveCredentials({ + required String serverUrl, + required String token, + required String clientIdentifier, + }) async { + await Future.wait([ + saveServerUrl(serverUrl), + saveToken(token), + saveClientIdentifier(clientIdentifier), + ]); + } + + // Check if credentials exist + bool hasCredentials() { + return getServerUrl() != null && getToken() != null; + } + + // Clear all credentials + Future clearCredentials() async { + await Future.wait([ + _prefs.remove(_keyServerUrl), + _prefs.remove(_keyToken), + _prefs.remove(_keyPlexToken), + _prefs.remove(_keyServerData), + _prefs.remove(_keyClientId), + _prefs.remove(_keyUserProfile), + ]); + } + + // Get all credentials as a map + Map getCredentials() { + return { + 'serverUrl': getServerUrl(), + 'token': getToken(), + 'clientIdentifier': getClientIdentifier(), + }; + } + + // Selected Library Index + Future saveSelectedLibraryIndex(int index) async { + await _prefs.setInt(_keySelectedLibraryIndex, index); + } + + int? getSelectedLibraryIndex() { + return _prefs.getInt(_keySelectedLibraryIndex); + } + + // Library Filters (stored as JSON string) + Future saveLibraryFilters(Map filters) async { + final jsonString = json.encode(filters); + await _prefs.setString(_keyLibraryFilters, jsonString); + } + + Map getLibraryFilters() { + final jsonString = _prefs.getString(_keyLibraryFilters); + if (jsonString == null) return {}; + + try { + final decoded = json.decode(jsonString) as Map; + return decoded.map((key, value) => MapEntry(key, value.toString())); + } catch (e) { + return {}; + } + } + + // Clear library preferences + Future clearLibraryPreferences() async { + await Future.wait([ + _prefs.remove(_keySelectedLibraryIndex), + _prefs.remove(_keyLibraryFilters), + ]); + } + + // User Profile (stored as JSON string) + Future saveUserProfile(Map profileJson) async { + final jsonString = json.encode(profileJson); + await _prefs.setString(_keyUserProfile, jsonString); + } + + Map? getUserProfile() { + final jsonString = _prefs.getString(_keyUserProfile); + if (jsonString == null) return null; + + try { + return json.decode(jsonString) as Map; + } catch (e) { + return null; + } + } +} diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart new file mode 100644 index 00000000..70a4b2b2 --- /dev/null +++ b/lib/utils/app_logger.dart @@ -0,0 +1,24 @@ +import 'package:logger/logger.dart'; + +/// Centralized logger instance for the application. +/// +/// Usage: +/// ```dart +/// import 'package:plezy/utils/app_logger.dart'; +/// +/// appLogger.d('Debug message'); +/// appLogger.i('Info message'); +/// appLogger.w('Warning message'); +/// appLogger.e('Error message', error: e, stackTrace: stackTrace); +/// ``` +final appLogger = Logger( + printer: PrettyPrinter( + methodCount: 2, + errorMethodCount: 8, + lineLength: 120, + colors: true, + printEmojis: true, + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, + ), + level: Level.debug, +); diff --git a/lib/utils/language_codes.dart b/lib/utils/language_codes.dart new file mode 100644 index 00000000..31253696 --- /dev/null +++ b/lib/utils/language_codes.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; + +/// Helper class for converting between ISO 639-1 (2-letter) and ISO 639-2 (3-letter) language codes +class LanguageCodes { + static Map? _codes; + + /// Load the language codes from JSON + static Future initialize() async { + if (_codes != null) return; + + final jsonString = await rootBundle.loadString( + 'lib/data/iso_639_codes.json', + ); + _codes = json.decode(jsonString) as Map; + } + + /// Get all possible variations of a language code + /// Handles both ISO 639-1 (2-letter) and ISO 639-2 (3-letter) codes + /// Returns a list of codes to check against track languages + static List getVariations(String languageCode) { + if (_codes == null) { + throw StateError( + 'LanguageCodes not initialized. Call initialize() first.', + ); + } + + final normalized = languageCode.toLowerCase().trim(); + final variations = {normalized}; // Use Set to avoid duplicates + + // Check if it's a 2-letter code (ISO 639-1) + if (_codes!.containsKey(normalized)) { + final entry = _codes![normalized] as Map; + + // Add the 639-1 code + if (entry.containsKey('639-1')) { + variations.add((entry['639-1'] as String).toLowerCase()); + } + + // Add the 639-2 code + if (entry.containsKey('639-2')) { + variations.add((entry['639-2'] as String).toLowerCase()); + } + + // Add the 639-2/B code if it exists (bibliographic variant) + if (entry.containsKey('639-2/B')) { + variations.add((entry['639-2/B'] as String).toLowerCase()); + } + } else { + // It might be a 3-letter code, search for it + for (var entry in _codes!.values) { + final entryMap = entry as Map; + + // Check if this entry contains our code as 639-2 or 639-2/B + final code6392 = entryMap['639-2'] as String?; + final code6392B = entryMap['639-2/B'] as String?; + + if (code6392?.toLowerCase() == normalized || + code6392B?.toLowerCase() == normalized) { + // Add all variations from this entry + if (entryMap.containsKey('639-1')) { + variations.add((entryMap['639-1'] as String).toLowerCase()); + } + if (code6392 != null) { + variations.add(code6392.toLowerCase()); + } + if (code6392B != null) { + variations.add(code6392B.toLowerCase()); + } + break; + } + } + } + + return variations.toList(); + } + + /// Get the English name of a language from its code + static String? getLanguageName(String languageCode) { + if (_codes == null) return null; + + final normalized = languageCode.toLowerCase().trim(); + + // Check if it's a 2-letter code + if (_codes!.containsKey(normalized)) { + final entry = _codes![normalized] as Map; + return entry['name'] as String?; + } + + // Search for 3-letter code + for (var entry in _codes!.values) { + final entryMap = entry as Map; + final code6392 = entryMap['639-2'] as String?; + final code6392B = entryMap['639-2/B'] as String?; + + if (code6392?.toLowerCase() == normalized || + code6392B?.toLowerCase() == normalized) { + return entryMap['name'] as String?; + } + } + + return null; + } +} diff --git a/lib/utils/plex_headers.dart b/lib/utils/plex_headers.dart new file mode 100644 index 00000000..f5ead39c --- /dev/null +++ b/lib/utils/plex_headers.dart @@ -0,0 +1,37 @@ +/// Utility class for building Plex API headers +class PlexHeaders { + /// Standard Plex headers required for API requests + static const String plexClientIdentifier = 'X-Plex-Client-Identifier'; + static const String plexProduct = 'X-Plex-Product'; + static const String plexVersion = 'X-Plex-Version'; + static const String plexToken = 'X-Plex-Token'; + static const String plexPlatform = 'X-Plex-Platform'; + static const String plexPlatformVersion = 'X-Plex-Platform-Version'; + static const String plexDevice = 'X-Plex-Device'; + + /// Builds standard Plex headers with optional token + static Map buildHeaders({ + required String clientIdentifier, + String? token, + String product = 'Plezy', + String version = '1.0', + String platform = 'Flutter', + String platformVersion = '1.0', + String device = 'Mobile', + }) { + final headers = { + plexClientIdentifier: clientIdentifier, + plexProduct: product, + plexVersion: version, + plexPlatform: platform, + plexPlatformVersion: platformVersion, + plexDevice: device, + }; + + if (token != null) { + headers[plexToken] = token; + } + + return headers; + } +} diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart new file mode 100644 index 00000000..0d82667f --- /dev/null +++ b/lib/widgets/desktop_app_bar.dart @@ -0,0 +1,265 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.dart'; +import '../services/fullscreen_state_manager.dart'; + +class DesktopWindowPadding { + /// Left padding for macOS traffic lights (normal window mode) + static const double macOSLeft = 80.0; + + /// Left padding for macOS in fullscreen (reduced since traffic lights auto-hide) + static const double macOSLeftFullscreen = 0.0; + + /// Right padding for macOS to prevent actions from being too close to edge + static const double macOSRight = 16.0; +} + +/// A widget that adds padding to account for desktop window controls. +/// On macOS, adds left padding for traffic lights (reduced in fullscreen). +class DesktopTitleBarPadding extends StatelessWidget { + final Widget child; + final double? leftPadding; + final double? rightPadding; + + const DesktopTitleBarPadding({ + super.key, + required this.child, + this.leftPadding, + this.rightPadding, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + double left = 0.0; + double right = 0.0; + + if (Platform.isMacOS) { + final isFullscreen = FullscreenStateManager().isFullscreen; + // In fullscreen, use minimal padding since traffic lights auto-hide + left = + leftPadding ?? + (isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft); + } + + if (left == 0.0 && right == 0.0) { + return child; + } + + return Padding( + padding: EdgeInsets.only(left: left, right: right), + child: child, + ); + }, + ); + } +} + +/// A custom app bar that automatically handles desktop window controls spacing. +/// Use this instead of AppBar for consistent desktop platform behavior. +class DesktopAppBar extends StatelessWidget implements PreferredSizeWidget { + final Widget? title; + final List? actions; + final Widget? leading; + final bool automaticallyImplyLeading; + final double? elevation; + final Color? backgroundColor; + final Color? surfaceTintColor; + final Color? shadowColor; + final double? scrolledUnderElevation; + + const DesktopAppBar({ + super.key, + this.title, + this.actions, + this.leading, + this.automaticallyImplyLeading = true, + this.elevation, + this.backgroundColor, + this.surfaceTintColor, + this.shadowColor, + this.scrolledUnderElevation, + }); + + @override + Widget build(BuildContext context) { + // Add right padding for desktop platforms + List? adjustedActions = actions; + + if (Platform.isMacOS) { + // macOS: Add padding to keep actions away from edge + if (actions != null) { + adjustedActions = [ + ...actions!, + SizedBox(width: DesktopWindowPadding.macOSRight), + ]; + } else { + adjustedActions = [SizedBox(width: DesktopWindowPadding.macOSRight)]; + } + } + + // Wrap leading widget with padding on macOS to avoid traffic lights + Widget? adjustedLeading = leading; + if (Platform.isMacOS && leading != null) { + adjustedLeading = ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + final leftPadding = isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft; + return Padding( + padding: EdgeInsets.only(left: leftPadding), + child: leading, + ); + }, + ); + } + + final appBar = AppBar( + title: title != null ? DesktopTitleBarPadding(child: title!) : null, + actions: adjustedActions, + leading: adjustedLeading, + automaticallyImplyLeading: automaticallyImplyLeading, + elevation: elevation, + backgroundColor: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + scrolledUnderElevation: scrolledUnderElevation, + ); + + // On macOS with transparent titlebar, wrap in GestureDetector to prevent + // window dragging and allow buttons to be clickable + if (Platform.isMacOS) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: appBar, + ); + } + + return appBar; + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + +/// A custom sliver app bar that automatically handles desktop window controls spacing. +/// Use this instead of SliverAppBar for consistent desktop platform behavior. +class DesktopSliverAppBar extends StatelessWidget { + final Widget? title; + final List? actions; + final Widget? leading; + final bool automaticallyImplyLeading; + final double? elevation; + final Color? backgroundColor; + final Color? surfaceTintColor; + final Color? shadowColor; + final double? scrolledUnderElevation; + final bool floating; + final bool pinned; + final double? expandedHeight; + final Widget? flexibleSpace; + final PreferredSizeWidget? bottom; + + const DesktopSliverAppBar({ + super.key, + this.title, + this.actions, + this.leading, + this.automaticallyImplyLeading = true, + this.elevation, + this.backgroundColor, + this.surfaceTintColor, + this.shadowColor, + this.scrolledUnderElevation, + this.floating = false, + this.pinned = false, + this.expandedHeight, + this.flexibleSpace, + this.bottom, + }); + + @override + Widget build(BuildContext context) { + // Add right padding for desktop platforms + List? adjustedActions = actions; + + if (Platform.isMacOS) { + // macOS: Add padding to keep actions away from edge + if (actions != null) { + adjustedActions = [ + ...actions!, + SizedBox(width: DesktopWindowPadding.macOSRight), + ]; + } else { + adjustedActions = [SizedBox(width: DesktopWindowPadding.macOSRight)]; + } + } + + // Wrap leading widget with gesture detector and padding on macOS + Widget? adjustedLeading = leading; + if (Platform.isMacOS && leading != null) { + adjustedLeading = ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + final leftPadding = isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: + (_) {}, // Consume pan gestures to prevent window dragging + child: Padding( + padding: EdgeInsets.only(left: leftPadding), + child: leading, + ), + ); + }, + ); + } + + // Wrap flexible space with gesture detector on macOS to prevent window dragging + Widget? adjustedFlexibleSpace = flexibleSpace; + if (Platform.isMacOS && flexibleSpace != null) { + adjustedFlexibleSpace = GestureDetector( + behavior: HitTestBehavior.translucent, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: flexibleSpace, + ); + } + + // On macOS, increase leading width to account for traffic light spacing + double? leadingWidth; + if (Platform.isMacOS && leading != null) { + final isFullscreen = FullscreenStateManager().isFullscreen; + final leftPadding = isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft; + leadingWidth = leftPadding + kToolbarHeight; + } + + return SliverAppBar( + title: title != null ? DesktopTitleBarPadding(child: title!) : null, + actions: adjustedActions, + leading: adjustedLeading, + leadingWidth: leadingWidth, + automaticallyImplyLeading: automaticallyImplyLeading, + elevation: elevation, + backgroundColor: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + scrolledUnderElevation: scrolledUnderElevation, + floating: floating, + pinned: pinned, + expandedHeight: expandedHeight, + flexibleSpace: adjustedFlexibleSpace, + bottom: bottom, + ); + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart new file mode 100644 index 00000000..82672113 --- /dev/null +++ b/lib/widgets/media_card.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../screens/media_detail_screen.dart'; +import '../screens/season_detail_screen.dart'; +import '../screens/video_player_screen.dart'; +import 'media_context_menu.dart'; + +class MediaCard extends StatefulWidget { + final PlexClient client; + final PlexMetadata item; + final double? width; + final double? height; + final VoidCallback? onRefresh; + final PlexUserProfile? userProfile; + + const MediaCard({ + super.key, + required this.client, + required this.item, + this.width, + this.height, + this.onRefresh, + this.userProfile, + }); + + @override + State createState() => _MediaCardState(); +} + +class _MediaCardState extends State { + void _handleTap(BuildContext context) async { + final itemType = widget.item.type.toLowerCase(); + + // For episodes, start playback directly + if (itemType == 'episode') { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + client: widget.client, + metadata: widget.item, + userProfile: widget.userProfile, + ), + ), + ); + // Refresh parent screen if result indicates it's needed + if (result == true) { + widget.onRefresh?.call(); + } + } else if (itemType == 'season') { + // For seasons, show season detail screen + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeasonDetailScreen( + client: widget.client, + season: widget.item, + userProfile: widget.userProfile, + ), + ), + ); + // Season screen doesn't return a refresh flag, but we can refresh anyway + widget.onRefresh?.call(); + } else { + // For all other types (shows, movies), show detail screen + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen( + client: widget.client, + metadata: widget.item, + userProfile: widget.userProfile, + ), + ), + ); + // Refresh parent screen if result indicates it's needed + if (result == true) { + widget.onRefresh?.call(); + } + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + child: MediaContextMenu( + client: widget.client, + metadata: widget.item, + onRefresh: widget.onRefresh, + onTap: () => _handleTap(context), + child: InkWell( + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Poster + if (widget.height != null) + SizedBox( + width: double.infinity, + height: widget.height, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: double.infinity, + height: widget.height, + child: _buildPosterImage(context), + ), + ), + if (widget.item.isWatched) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 16, + ), + ), + ), + // Progress bar for partially watched episodes + if (widget.item.viewOffset != null && + widget.item.duration != null && + widget.item.viewOffset! > 0 && + !widget.item.isWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + child: LinearProgressIndicator( + value: + widget.item.viewOffset! / + widget.item.duration!, + backgroundColor: Colors.black.withValues( + alpha: 0.5, + ), + valueColor: const AlwaysStoppedAnimation( + Colors.red, + ), + minHeight: 4, + ), + ), + ), + ], + ), + ) + else + Expanded( + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildPosterImage(context), + ), + if (widget.item.isWatched) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 16, + ), + ), + ), + // Progress bar for partially watched episodes + if (widget.item.viewOffset != null && + widget.item.duration != null && + widget.item.viewOffset! > 0 && + !widget.item.isWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + child: LinearProgressIndicator( + value: + widget.item.viewOffset! / + widget.item.duration!, + backgroundColor: Colors.black.withValues( + alpha: 0.5, + ), + valueColor: const AlwaysStoppedAnimation( + Colors.red, + ), + minHeight: 4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + // Text content + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + widget.item.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + height: 1.1, + ), + ), + if (widget.item.displaySubtitle != null) + Text( + widget.item.displaySubtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + height: 1.1, + ), + ) + else if (widget.item.parentTitle != null) + Text( + widget.item.parentTitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + height: 1.1, + ), + ) + else if (widget.item.year != null) + Text( + '${widget.item.year}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + height: 1.1, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildPosterImage(BuildContext context) { + if (widget.item.posterThumb != null) { + return CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl(widget.item.posterThumb), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (context, url) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.broken_image, size: 40)), + ), + ); + } else { + return Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.movie, size: 40)), + ); + } + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart new file mode 100644 index 00000000..fd6eb531 --- /dev/null +++ b/lib/widgets/media_context_menu.dart @@ -0,0 +1,285 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../screens/media_detail_screen.dart'; +import '../screens/season_detail_screen.dart'; + +/// Helper class to store menu action data +class _MenuAction { + final String value; + final IconData icon; + final String label; + + _MenuAction({ + required this.value, + required this.icon, + required this.label, + }); +} + +/// A reusable wrapper widget that adds a context menu (long press / right click) +/// to any media item with appropriate actions based on the item type. +class MediaContextMenu extends StatefulWidget { + final PlexClient client; + final PlexMetadata metadata; + final VoidCallback? onRefresh; + final VoidCallback? onTap; + final Widget child; + + const MediaContextMenu({ + super.key, + required this.client, + required this.metadata, + this.onRefresh, + this.onTap, + required this.child, + }); + + @override + State createState() => _MediaContextMenuState(); +} + +class _MediaContextMenuState extends State { + Offset? _tapPosition; + + void _storeTapPosition(TapDownDetails details) { + _tapPosition = details.globalPosition; + } + + void _showContextMenu(BuildContext context) async { + final itemType = widget.metadata.type.toLowerCase(); + final isPartiallyWatched = + widget.metadata.viewedLeafCount != null && + widget.metadata.leafCount != null && + widget.metadata.viewedLeafCount! > 0 && + widget.metadata.viewedLeafCount! < widget.metadata.leafCount!; + + // Check if we should use bottom sheet (on iOS and Android) + final useBottomSheet = Platform.isIOS || Platform.isAndroid; + + // Build menu actions + final menuActions = <_MenuAction>[]; + + // Mark as Watched + if (!widget.metadata.isWatched || isPartiallyWatched) { + menuActions.add( + _MenuAction( + value: 'watch', + icon: Icons.check_circle_outline, + label: 'Mark as Watched', + ), + ); + } + + // Mark as Unwatched + if (widget.metadata.isWatched || isPartiallyWatched) { + menuActions.add( + _MenuAction( + value: 'unwatch', + icon: Icons.remove_circle_outline, + label: 'Mark as Unwatched', + ), + ); + } + + // Go to Series (for episodes and seasons) + if ((itemType == 'episode' || itemType == 'season') && + widget.metadata.grandparentTitle != null) { + menuActions.add( + _MenuAction( + value: 'series', + icon: Icons.tv, + label: 'Go to series', + ), + ); + } + + // Go to Season (for episodes) + if (itemType == 'episode' && widget.metadata.parentTitle != null) { + menuActions.add( + _MenuAction( + value: 'season', + icon: Icons.playlist_play, + label: 'Go to season', + ), + ); + } + + String? selected; + + if (useBottomSheet) { + // Show bottom sheet on mobile + selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + widget.metadata.title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ...menuActions.map((action) => ListTile( + leading: Icon(action.icon), + title: Text(action.label), + onTap: () => Navigator.pop(context, action.value), + )), + ], + ), + ), + ); + } else { + // Show popup menu on larger screens + final menuItems = menuActions.map((action) => PopupMenuItem( + value: action.value, + child: Row( + children: [ + Icon(action.icon), + const SizedBox(width: 12), + Expanded(child: Text(action.label)), + ], + ), + )).toList(); + + // Use stored tap position or fallback to widget position + final RenderBox? overlay = + Overlay.of(context).context.findRenderObject() as RenderBox?; + + Offset position; + if (_tapPosition != null) { + position = _tapPosition!; + } else { + final RenderBox renderBox = context.findRenderObject() as RenderBox; + position = renderBox.localToGlobal(Offset.zero, ancestor: overlay); + } + + selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy, + position.dx, + position.dy, + ), + items: menuItems, + ); + } + + if (!context.mounted) return; + + switch (selected) { + case 'watch': + try { + await widget.client.markAsWatched(widget.metadata.ratingKey); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Marked as watched'))); + // Refresh parent screen to update UI + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + break; + case 'unwatch': + try { + await widget.client.markAsUnwatched(widget.metadata.ratingKey); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Marked as unwatched')), + ); + // Refresh parent screen to update UI + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + break; + case 'series': + // Navigate to series detail screen + if (widget.metadata.grandparentRatingKey != null) { + try { + final seriesMetadata = await widget.client.getMetadata( + widget.metadata.grandparentRatingKey!, + ); + if (seriesMetadata != null && context.mounted) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen( + client: widget.client, + metadata: seriesMetadata, + ), + ), + ); + // Refresh parent screen after returning + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading series: $e')), + ); + } + } + } + break; + case 'season': + // Navigate to season detail screen + if (widget.metadata.parentRatingKey != null) { + try { + final seasonMetadata = await widget.client.getMetadata( + widget.metadata.parentRatingKey!, + ); + if (seasonMetadata != null && context.mounted) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeasonDetailScreen( + client: widget.client, + season: seasonMetadata, + ), + ), + ); + // Refresh parent screen after returning + widget.onRefresh?.call(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading season: $e')), + ); + } + } + } + break; + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: widget.onTap, + onTapDown: _storeTapPosition, + onLongPress: () => _showContextMenu(context), + onSecondaryTapDown: _storeTapPosition, + onSecondaryTap: () => _showContextMenu(context), + child: widget.child, + ); + } +} diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart new file mode 100644 index 00000000..de07f8af --- /dev/null +++ b/lib/widgets/plex_video_controls.dart @@ -0,0 +1,1399 @@ +import 'dart:async'; +import 'dart:io' show Platform; +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:macos_window_utils/macos_window_utils.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_media_info.dart'; +import '../services/fullscreen_state_manager.dart'; +import 'desktop_app_bar.dart'; + +/// Custom video controls builder for Plex with chapter, audio, and subtitle support +Widget plexVideoControlsBuilder( + Player player, + PlexClient client, + PlexMetadata metadata, { + VoidCallback? onNext, + VoidCallback? onPrevious, +}) { + return PlexVideoControls( + player: player, + client: client, + metadata: metadata, + onNext: onNext, + onPrevious: onPrevious, + ); +} + +class PlexVideoControls extends StatefulWidget { + final Player player; + final PlexClient client; + final PlexMetadata metadata; + final VoidCallback? onNext; + final VoidCallback? onPrevious; + + const PlexVideoControls({ + super.key, + required this.player, + required this.client, + required this.metadata, + this.onNext, + this.onPrevious, + }); + + @override + State createState() => _PlexVideoControlsState(); +} + +class _PlexVideoControlsState extends State + with WindowListener { + bool _showControls = true; + List _chapters = []; + bool _chaptersLoaded = false; + Timer? _hideTimer; + bool _isFullscreen = false; + + @override + void initState() { + super.initState(); + _loadChapters(); + _startHideTimer(); + // Add window listener for tracking fullscreen state (for button icon) + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + windowManager.addListener(this); + } + } + + @override + void dispose() { + _hideTimer?.cancel(); + // Remove window listener + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + windowManager.removeListener(this); + } + super.dispose(); + } + + @override + void onWindowEnterFullScreen() { + if (mounted) { + setState(() { + _isFullscreen = true; + }); + } + } + + @override + void onWindowLeaveFullScreen() { + if (mounted) { + setState(() { + _isFullscreen = false; + }); + } + } + + @override + void onWindowMaximize() { + // On macOS, maximize is the same as fullscreen (green button) + if (mounted && Platform.isMacOS) { + setState(() { + _isFullscreen = true; + }); + } + } + + @override + void onWindowUnmaximize() { + // On macOS, unmaximize means exiting fullscreen + if (mounted && Platform.isMacOS) { + setState(() { + _isFullscreen = false; + }); + } + } + + void _startHideTimer() { + _hideTimer?.cancel(); + // Only auto-hide if playing + if (widget.player.state.playing) { + _hideTimer = Timer(const Duration(seconds: 3), () { + if (mounted && widget.player.state.playing) { + setState(() { + _showControls = false; + }); + // Hide traffic lights on macOS when controls auto-hide + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + }); + } + } + + void _toggleControls() { + setState(() { + _showControls = !_showControls; + }); + if (_showControls) { + _startHideTimer(); + } + + // On macOS, hide/show traffic lights with controls + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + } + + void _updateTrafficLightVisibility() async { + if (Platform.isMacOS) { + if (_showControls) { + await WindowManipulator.showCloseButton(); + await WindowManipulator.showMiniaturizeButton(); + await WindowManipulator.showZoomButton(); + } else { + await WindowManipulator.hideCloseButton(); + await WindowManipulator.hideMiniaturizeButton(); + await WindowManipulator.hideZoomButton(); + } + } + } + + Future _loadChapters() async { + final chapters = await widget.client.getChapters(widget.metadata.ratingKey); + if (mounted) { + setState(() { + _chapters = chapters; + _chaptersLoaded = true; + }); + } + } + + bool _isMobile(BuildContext context) { + final platform = Theme.of(context).platform; + return platform == TargetPlatform.iOS || platform == TargetPlatform.android; + } + + void _seekToPreviousChapter() { + if (_chapters.isEmpty) { + // No chapters - seek backward 10 seconds + final currentPosition = widget.player.state.position; + widget.player.seek(currentPosition - const Duration(seconds: 10)); + return; + } + + final currentPosition = widget.player.state.position.inMilliseconds; + + // Find current chapter + for (int i = _chapters.length - 1; i >= 0; i--) { + final chapterStart = _chapters[i].startTimeOffset ?? 0; + if (currentPosition > chapterStart + 3000) { + // If more than 3 seconds into chapter, go to start of current chapter + widget.player.seek(Duration(milliseconds: chapterStart)); + return; + } + } + + // If at start of first chapter, go to beginning + widget.player.seek(Duration.zero); + } + + void _seekToNextChapter() { + if (_chapters.isEmpty) { + // No chapters - seek forward 10 seconds + final currentPosition = widget.player.state.position; + widget.player.seek(currentPosition + const Duration(seconds: 10)); + return; + } + + final currentPosition = widget.player.state.position.inMilliseconds; + + // Find next chapter + for (int i = 0; i < _chapters.length; i++) { + final chapterStart = _chapters[i].startTimeOffset ?? 0; + if (chapterStart > currentPosition) { + widget.player.seek(Duration(milliseconds: chapterStart)); + return; + } + } + } + + Future _toggleFullscreen() async { + if (!_isMobile(context)) { + setState(() { + _isFullscreen = !_isFullscreen; + }); + + if (Platform.isMacOS) { + // Use native macOS fullscreen - titlebar is handled automatically + if (_isFullscreen) { + await WindowManipulator.enterFullscreen(); + } else { + await WindowManipulator.exitFullscreen(); + } + } else { + // For Windows/Linux, use window_manager + if (_isFullscreen) { + await windowManager.setFullScreen(true); + } else { + await windowManager.setFullScreen(false); + } + } + } + } + + @override + Widget build(BuildContext context) { + final isMobile = _isMobile(context); + + return Stack( + children: [ + // Invisible tap detector that always covers the full area + Positioned.fill( + child: GestureDetector( + onTap: _toggleControls, + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + // Custom controls overlay - use AnimatedOpacity to keep widget tree alive + Positioned.fill( + child: IgnorePointer( + ignoring: !_showControls, + child: AnimatedOpacity( + opacity: _showControls ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: GestureDetector( + onTap: _toggleControls, + behavior: HitTestBehavior.deferToChild, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + ], + stops: const [0.0, 0.2, 0.8, 1.0], + ), + ), + child: isMobile + ? _buildMobileLayout() + : _buildDesktopLayout(), + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildMobileLayout() { + return Column( + children: [ + // Top bar with back button and track/chapter controls + _buildMobileTopBar(), + const Spacer(), + // Centered large playback controls + _buildMobilePlaybackControls(), + const Spacer(), + // Progress bar at bottom + _buildMobileBottomBar(), + ], + ); + } + + Widget _buildDesktopLayout() { + return Column( + children: [ + // Top bar with back button and title + _buildDesktopTopBar(), + const Spacer(), + // Bottom controls + _buildDesktopBottomControls(), + ], + ); + } + + // Mobile layout components + Widget _buildMobileTopBar() { + final topBar = Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(true), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.metadata.grandparentTitle ?? widget.metadata.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.metadata.parentIndex != null && + widget.metadata.index != null) + Text( + 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', + style: const TextStyle(color: Colors.white70, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Track and chapter controls in top right + IconButton( + icon: const Icon(Icons.audiotrack, color: Colors.white), + onPressed: _showAudioBottomSheet, + ), + IconButton( + icon: const Icon(Icons.subtitles, color: Colors.white), + onPressed: _showSubtitleBottomSheet, + ), + IconButton( + icon: const Icon(Icons.video_library, color: Colors.white), + onPressed: _showChapterBottomSheet, + ), + ], + ), + ); + + // On macOS, wrap with GestureDetector to prevent window dragging + if (Platform.isMacOS) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: topBar, + ); + } + + return topBar; + } + + Widget _buildMobilePlaybackControls() { + return StreamBuilder( + stream: widget.player.stream.playing, + builder: (context, snapshot) { + final isPlaying = snapshot.data ?? false; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon( + Icons.replay_10, + color: Colors.white, + size: 48, + ), + iconSize: 48, + onPressed: () { + final currentPosition = widget.player.state.position; + widget.player.seek( + currentPosition - const Duration(seconds: 10), + ); + }, + ), + ), + const SizedBox(width: 48), + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 72, + ), + iconSize: 72, + onPressed: () { + if (isPlaying) { + widget.player.pause(); + _hideTimer?.cancel(); // Cancel auto-hide when paused + } else { + widget.player.play(); + _startHideTimer(); // Start auto-hide when playing + } + }, + ), + ), + const SizedBox(width: 48), + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: const Icon( + Icons.forward_10, + color: Colors.white, + size: 48, + ), + iconSize: 48, + onPressed: () { + final currentPosition = widget.player.state.position; + widget.player.seek( + currentPosition + const Duration(seconds: 10), + ); + }, + ), + ), + ], + ); + }, + ); + } + + Widget _buildMobileBottomBar() { + return Padding( + padding: const EdgeInsets.all(16), + child: StreamBuilder( + stream: widget.player.stream.position, + builder: (context, positionSnapshot) { + return StreamBuilder( + stream: widget.player.stream.duration, + builder: (context, durationSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final duration = durationSnapshot.data ?? Duration.zero; + + return Column( + children: [ + _buildTimelineWithChapters( + position: position, + duration: duration, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(position), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + Text( + _formatDuration(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ); + }, + ); + }, + ), + ); + } + + // Desktop layout components + Widget _buildDesktopTopBar() { + // Use global fullscreen state for padding + return ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + // In fullscreen on macOS, use less left padding since traffic lights auto-hide + // In normal mode on macOS, need more padding to avoid traffic lights + final leftPadding = Platform.isMacOS + ? (isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft) + : DesktopWindowPadding.macOSLeftFullscreen; + + return _buildDesktopTopBarContent(leftPadding); + }, + ); + } + + Widget _buildDesktopTopBarContent(double leftPadding) { + // On macOS, use single-line layout to align with traffic lights + // On other platforms, use two-line layout with series and episode info + final isMacOS = Platform.isMacOS; + + final topBar = Padding( + padding: EdgeInsets.only( + left: leftPadding, + right: 16, + top: isMacOS ? 8 : 16, + bottom: isMacOS ? 8 : 16, + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(true), + ), + const SizedBox(width: 16), + Expanded( + child: isMacOS + ? _buildMacOSSingleLineTitle() + : _buildMultiLineTitle(), + ), + ], + ), + ); + + // On macOS, wrap with GestureDetector to prevent window dragging + if (Platform.isMacOS) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: topBar, + ); + } + + return topBar; + } + + Widget _buildMacOSSingleLineTitle() { + // Build single-line title combining series and episode info + final seriesName = + widget.metadata.grandparentTitle ?? widget.metadata.title; + final hasEpisodeInfo = + widget.metadata.parentIndex != null && widget.metadata.index != null; + + final titleText = hasEpisodeInfo + ? '$seriesName · S${widget.metadata.parentIndex} E${widget.metadata.index} · ${widget.metadata.title}' + : seriesName; + + return Text( + titleText, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + Widget _buildMultiLineTitle() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.metadata.grandparentTitle ?? widget.metadata.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.metadata.parentIndex != null && + widget.metadata.index != null) + Text( + 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', + style: const TextStyle(color: Colors.white70, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + + Widget _buildDesktopBottomControls() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + children: [ + // Row 1: Timeline with time indicators + StreamBuilder( + stream: widget.player.stream.position, + builder: (context, positionSnapshot) { + return StreamBuilder( + stream: widget.player.stream.duration, + builder: (context, durationSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final duration = durationSnapshot.data ?? Duration.zero; + + return Row( + children: [ + Text( + _formatDuration(position), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildTimelineWithChapters( + position: position, + duration: duration, + ), + ), + const SizedBox(width: 12), + Text( + _formatDuration(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ); + }, + ); + }, + ), + const SizedBox(height: 4), + // Row 2: Playback controls and options + Row( + children: [ + // Previous item + IconButton( + icon: Icon( + Icons.skip_previous, + color: widget.onPrevious != null + ? Colors.white + : Colors.white54, + ), + onPressed: widget.onPrevious, + ), + // Previous chapter (or -10s if no chapters) + IconButton( + icon: Icon( + _chapters.isEmpty ? Icons.replay_10 : Icons.fast_rewind, + color: Colors.white, + ), + onPressed: _seekToPreviousChapter, + ), + // Play/Pause + StreamBuilder( + stream: widget.player.stream.playing, + builder: (context, snapshot) { + final isPlaying = snapshot.data ?? false; + return IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 32, + ), + iconSize: 32, + onPressed: () { + if (isPlaying) { + widget.player.pause(); + _hideTimer?.cancel(); // Cancel auto-hide when paused + } else { + widget.player.play(); + _startHideTimer(); // Start auto-hide when playing + } + }, + ); + }, + ), + // Next chapter (or +10s if no chapters) + IconButton( + icon: Icon( + _chapters.isEmpty ? Icons.forward_10 : Icons.fast_forward, + color: Colors.white, + ), + onPressed: _seekToNextChapter, + ), + // Next item + IconButton( + icon: Icon( + Icons.skip_next, + color: widget.onNext != null ? Colors.white : Colors.white54, + ), + onPressed: widget.onNext, + ), + const Spacer(), + // Volume control + _buildVolumeControl(), + const SizedBox(width: 16), + // Audio track + IconButton( + icon: const Icon(Icons.audiotrack, color: Colors.white), + onPressed: _showAudioBottomSheet, + ), + // Subtitle track + IconButton( + icon: const Icon(Icons.subtitles, color: Colors.white), + onPressed: _showSubtitleBottomSheet, + ), + // Chapters + IconButton( + icon: const Icon(Icons.video_library, color: Colors.white), + onPressed: _showChapterBottomSheet, + ), + // Fullscreen + IconButton( + icon: Icon( + _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + color: Colors.white, + ), + onPressed: _toggleFullscreen, + ), + ], + ), + ], + ), + ); + } + + Widget _buildTimelineWithChapters({ + required Duration position, + required Duration duration, + }) { + return Stack( + alignment: Alignment.center, + children: [ + // Chapter markers layer + if (_chaptersLoaded && + _chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: + _chapters.map((chapter) { + final chapterPosition = + (chapter.startTimeOffset ?? 0) / + duration.inMilliseconds; + return Expanded( + flex: (chapterPosition * 1000).toInt(), + child: const SizedBox(), + ); + }).toList()..add( + Expanded( + flex: + 1000 - + _chapters.fold( + 0, + (sum, chapter) => + sum + + ((chapter.startTimeOffset ?? 0) / + duration.inMilliseconds * + 1000) + .toInt(), + ), + child: const SizedBox(), + ), + ), + ), + ), + ), + // Slider + Slider( + value: duration.inMilliseconds > 0 + ? position.inMilliseconds.toDouble() + : 0.0, + min: 0.0, + max: duration.inMilliseconds.toDouble(), + onChanged: (value) { + widget.player.seek(Duration(milliseconds: value.toInt())); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + // Chapter marker indicators + if (_chaptersLoaded && + _chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: CustomPaint( + painter: ChapterMarkerPainter( + chapters: _chapters, + duration: duration, + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildVolumeControl() { + return StreamBuilder( + stream: widget.player.stream.volume, + initialData: widget.player.state.volume, + builder: (context, snapshot) { + final volume = snapshot.data ?? 100.0; + final isMuted = volume == 0; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + isMuted ? Icons.volume_off : Icons.volume_up, + color: Colors.white, + ), + onPressed: () { + if (isMuted) { + widget.player.setVolume(100.0); + } else { + widget.player.setVolume(0.0); + } + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 8), + SizedBox( + width: 100, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 3, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 12, + ), + ), + child: Slider( + value: volume, + min: 0.0, + max: 100.0, + onChanged: (value) { + widget.player.setVolume(value); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + ), + ), + ], + ); + }, + ); + } + + BoxConstraints _getBottomSheetConstraints() { + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 600; + + return BoxConstraints( + maxWidth: isDesktop ? 700 : double.infinity, + maxHeight: isDesktop ? 400 : size.height * 0.75, + minHeight: isDesktop ? 300 : size.height * 0.5, + ); + } + + void _showAudioBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: _getBottomSheetConstraints(), + builder: (context) => StreamBuilder( + stream: widget.player.stream.tracks, + initialData: widget.player.state.tracks, + builder: (context, snapshot) { + final tracks = snapshot.data; + final audioTracks = (tracks?.audio ?? []) + .where((track) => track.id != 'auto' && track.id != 'no') + .toList(); + + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.audiotrack, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Audio Tracks', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + if (audioTracks.isEmpty) + const Expanded( + child: Center( + child: Text( + 'No audio tracks available', + style: TextStyle(color: Colors.white70), + ), + ), + ) + else + Expanded( + child: StreamBuilder( + stream: widget.player.stream.track, + initialData: widget.player.state.track, + builder: (context, selectedSnapshot) { + final selectedTrack = selectedSnapshot.data?.audio; + final selectedId = selectedTrack?.id; + + return ListView.builder( + itemCount: audioTracks.length, + itemBuilder: (context, index) { + final audioTrack = audioTracks[index]; + final isSelected = audioTrack.id == selectedId; + + final parts = []; + if (audioTrack.title != null && + audioTrack.title!.isNotEmpty) { + parts.add(audioTrack.title!); + } + if (audioTrack.language != null && + audioTrack.language!.isNotEmpty) { + parts.add(audioTrack.language!.toUpperCase()); + } + if (audioTrack.codec != null && + audioTrack.codec!.isNotEmpty) { + parts.add(audioTrack.codec!.toUpperCase()); + } + if (audioTrack.channelscount != null) { + parts.add('${audioTrack.channelscount}ch'); + } + + final label = parts.isEmpty + ? 'Audio Track ${index + 1}' + : parts.join(' · '); + + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected + ? Colors.blue + : Colors.white, + ), + ), + trailing: isSelected + ? const Icon( + Icons.check, + color: Colors.blue, + ) + : null, + onTap: () { + widget.player.setAudioTrack(audioTrack); + Navigator.pop(context); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + void _showSubtitleBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: _getBottomSheetConstraints(), + builder: (context) => StreamBuilder( + stream: widget.player.stream.tracks, + initialData: widget.player.state.tracks, + builder: (context, snapshot) { + final tracks = snapshot.data; + final subtitles = (tracks?.subtitle ?? []) + .where((track) => track.id != 'auto' && track.id != 'no') + .toList(); + + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.subtitles, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Subtitles', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + if (subtitles.isEmpty) + const Expanded( + child: Center( + child: Text( + 'No subtitles available', + style: TextStyle(color: Colors.white70), + ), + ), + ) + else + Expanded( + child: StreamBuilder( + stream: widget.player.stream.track, + initialData: widget.player.state.track, + builder: (context, selectedSnapshot) { + final selectedTrack = selectedSnapshot.data?.subtitle; + final selectedId = selectedTrack?.id; + final isOffSelected = selectedId == 'no'; + + return ListView.builder( + itemCount: + subtitles.length + 1, // +1 for "Off" option + itemBuilder: (context, index) { + // First item is "Off" + if (index == 0) { + return ListTile( + title: const Text( + 'Off', + style: TextStyle(color: Colors.white), + ), + trailing: isOffSelected + ? const Icon( + Icons.check, + color: Colors.blue, + ) + : null, + onTap: () { + widget.player.setSubtitleTrack( + SubtitleTrack.no(), + ); + Navigator.pop(context); + }, + ); + } + + // Subsequent items are subtitle tracks + final subtitle = subtitles[index - 1]; + final isSelected = subtitle.id == selectedId; + + // Build label with available info + final parts = []; + if (subtitle.title != null && + subtitle.title!.isNotEmpty) { + parts.add(subtitle.title!); + } + if (subtitle.language != null && + subtitle.language!.isNotEmpty) { + parts.add(subtitle.language!.toUpperCase()); + } + if (subtitle.codec != null && + subtitle.codec!.isNotEmpty) { + // Format codec names nicely + String codecName = subtitle.codec! + .toUpperCase(); + if (codecName == 'SUBRIP') { + codecName = 'SRT'; + } else if (codecName == 'DVD_SUBTITLE') { + codecName = 'DVD'; + } else if (codecName == 'ASS' || + codecName == 'SSA') { + codecName = codecName; // Keep as-is + } else if (codecName == 'WEBVTT') { + codecName = 'VTT'; + } + parts.add(codecName); + } + + final label = parts.isEmpty + ? 'Track $index' + : parts.join(' · '); + + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected + ? Colors.blue + : Colors.white, + ), + ), + trailing: isSelected + ? const Icon( + Icons.check, + color: Colors.blue, + ) + : null, + onTap: () { + widget.player.setSubtitleTrack(subtitle); + Navigator.pop(context); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + void _showChapterBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: _getBottomSheetConstraints(), + builder: (context) => StreamBuilder( + stream: widget.player.stream.position, + initialData: widget.player.state.position, + builder: (context, positionSnapshot) { + final currentPosition = positionSnapshot.data ?? Duration.zero; + final currentPositionMs = currentPosition.inMilliseconds; + + // Find the current chapter based on position + int? currentChapterIndex; + for (int i = 0; i < _chapters.length; i++) { + final chapter = _chapters[i]; + final startMs = chapter.startTimeOffset ?? 0; + final endMs = + chapter.endTimeOffset ?? + (i < _chapters.length - 1 + ? _chapters[i + 1].startTimeOffset ?? 0 + : double.maxFinite.toInt()); + + if (currentPositionMs >= startMs && currentPositionMs < endMs) { + currentChapterIndex = i; + break; + } + } + + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.video_library, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Chapters', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + if (!_chaptersLoaded) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else if (_chapters.isEmpty) + const Expanded( + child: Center( + child: Text( + 'No chapters available', + style: TextStyle(color: Colors.white70), + ), + ), + ) + else + Expanded( + child: ListView.builder( + itemCount: _chapters.length, + itemBuilder: (context, index) { + final chapter = _chapters[index]; + final isCurrentChapter = currentChapterIndex == index; + + return ListTile( + leading: chapter.thumb != null + ? Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + widget.client.getThumbnailUrl( + chapter.thumb, + ), + width: 60, + height: 34, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ), + ), + ), + if (isCurrentChapter) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(4), + border: Border.all( + color: Colors.blue, + width: 2, + ), + ), + ), + ), + ], + ) + : null, + title: Text( + chapter.label, + style: TextStyle( + color: isCurrentChapter + ? Colors.blue + : Colors.white, + fontWeight: isCurrentChapter + ? FontWeight.bold + : FontWeight.normal, + ), + ), + subtitle: Text( + _formatDuration(chapter.startTime), + style: TextStyle( + color: isCurrentChapter + ? Colors.blue.withValues(alpha: 0.7) + : Colors.white70, + fontSize: 12, + ), + ), + trailing: isCurrentChapter + ? const Icon( + Icons.play_circle_filled, + color: Colors.blue, + ) + : null, + onTap: () { + widget.player.seek(chapter.startTime); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + String _formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + } +} + +/// Custom painter for drawing chapter markers on the timeline +class ChapterMarkerPainter extends CustomPainter { + final List chapters; + final Duration duration; + + ChapterMarkerPainter({required this.chapters, required this.duration}); + + @override + void paint(Canvas canvas, Size size) { + if (duration.inMilliseconds == 0) return; + + final paint = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + + for (final chapter in chapters) { + final startMs = chapter.startTimeOffset ?? 0; + if (startMs == 0) continue; // Skip first chapter marker at 0:00 + + final position = (startMs / duration.inMilliseconds) * size.width; + + // Draw short vertical line for chapter marker (centered on slider track) + canvas.drawLine( + Offset(position, size.height * 0.45), + Offset(position, size.height * 0.55), + paint, + ); + } + } + + @override + bool shouldRepaint(ChapterMarkerPainter oldDelegate) { + return oldDelegate.chapters != chapters || oldDelegate.duration != duration; + } +} diff --git a/lib/widgets/server_list_tile.dart b/lib/widgets/server_list_tile.dart new file mode 100644 index 00000000..54274e12 --- /dev/null +++ b/lib/widgets/server_list_tile.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import '../services/plex_auth_service.dart'; + +class ServerListTile extends StatelessWidget { + final PlexServer server; + final VoidCallback onTap; + final bool showTrailingIcon; + + const ServerListTile({ + super.key, + required this.server, + required this.onTap, + this.showTrailingIcon = true, + }); + + @override + Widget build(BuildContext context) { + final isOnline = server.isOnline; + + return ListTile( + leading: Icon( + Icons.dns, + color: isOnline + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + title: Text(server.name), + subtitle: Row( + children: [ + Icon( + isOnline ? Icons.circle : Icons.circle_outlined, + size: 10, + color: isOnline ? Colors.green : Colors.grey, + ), + const SizedBox(width: 4), + Text( + isOnline ? 'Online' : 'Offline', + style: TextStyle( + fontSize: 12, + color: isOnline ? Colors.green : Colors.grey, + ), + ), + const SizedBox(width: 8), + Text( + '•', + style: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Text( + server.owned ? 'Owned' : 'Shared', + style: const TextStyle(fontSize: 12), + ), + ], + ), + trailing: showTrailingIcon ? const Icon(Icons.chevron_right) : null, + onTap: onTap, + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 00000000..d3896c98 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 00000000..12906643 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "plezy") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.edde746.plezy") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000..d5bd0164 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..1e491337 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,35 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) volume_controller_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin"); + volume_controller_plugin_register_with_registrar(volume_controller_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..e0f0a47b --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000..2843f641 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + media_kit_libs_linux + media_kit_video + screen_retriever_linux + url_launcher_linux + volume_controller + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 00000000..41942947 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "Plezy"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "Plezy"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 00000000..72271d5e --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 00000000..746adbb6 --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..4b81f9b2 --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..5caa9d15 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 00000000..88d08936 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,34 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import macos_window_utils +import media_kit_libs_macos_video +import media_kit_video +import package_info_plus +import path_provider_foundation +import screen_retriever_macos +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos +import volume_controller +import wakelock_plus +import window_manager + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 00000000..ff5ddb3b --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 00000000..24704c75 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,91 @@ +PODS: + - FlutterMacOS (1.0.0) + - macos_window_utils (1.0.0): + - FlutterMacOS + - media_kit_libs_macos_video (1.0.4): + - FlutterMacOS + - media_kit_video (0.0.1): + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - screen_retriever_macos (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - volume_controller (0.0.1): + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS + - window_manager (0.2.0): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) + - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) + - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - volume_controller (from `Flutter/ephemeral/.symlinks/plugins/volume_controller/macos`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + macos_window_utils: + :path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos + media_kit_libs_macos_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos + media_kit_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + volume_controller: + :path: Flutter/ephemeral/.symlinks/plugins/volume_controller/macos + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d + media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 + media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd + volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd + wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..e4284584 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,800 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 0222508F6EE2A41A69A621F6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0B314F22FE6D00D0C7673 /* Pods_RunnerTests.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6AC86ED62EA70B4C0067BC66 /* plezy.icon */; }; + A3BC036FAB4B8E39B7126970 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 202C1CA899D4FEDC5F1A3000 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 053753D375D440BB1EC6B342 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 202C1CA899D4FEDC5F1A3000 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2BE1BA94C203ACCD120F0127 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 2EDB448AB472FA47ED7BA245 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* Plezy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Plezy.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 5CD0B314F22FE6D00D0C7673 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5F3D7650F7C0FBE711045DD4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + EB84EE33D6E7F697C803E14A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + EFA1AE35E23B7995D6C62CFD /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0222508F6EE2A41A69A621F6 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A3BC036FAB4B8E39B7126970 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 295400F6A94D53E806ED64A8 /* Pods */ = { + isa = PBXGroup; + children = ( + 2BE1BA94C203ACCD120F0127 /* Pods-Runner.debug.xcconfig */, + 2EDB448AB472FA47ED7BA245 /* Pods-Runner.release.xcconfig */, + 5F3D7650F7C0FBE711045DD4 /* Pods-Runner.profile.xcconfig */, + 053753D375D440BB1EC6B342 /* Pods-RunnerTests.debug.xcconfig */, + EB84EE33D6E7F697C803E14A /* Pods-RunnerTests.release.xcconfig */, + EFA1AE35E23B7995D6C62CFD /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 6AC86ED62EA70B4C0067BC66 /* plezy.icon */, + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 295400F6A94D53E806ED64A8 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* Plezy.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 202C1CA899D4FEDC5F1A3000 /* Pods_Runner.framework */, + 5CD0B314F22FE6D00D0C7673 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 2CF269D31FC1E8FDDD181D81 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 99EAC5D8B082B1DBE925E26C /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 1061B01F01C2AD298FA23F27 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* Plezy.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1061B01F01C2AD298FA23F27 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 2CF269D31FC1E8FDDD181D81 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 99EAC5D8B082B1DBE925E26C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 053753D375D440BB1EC6B342 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/plezy"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EB84EE33D6E7F697C803E14A /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/plezy"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EFA1AE35E23B7995D6C62CFD /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/plezy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/plezy"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = plezy; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..9917e837 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..b3c17614 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..2a199ece --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = Plezy + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1 + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..78c36cf4 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 00000000..08ba3a3f --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/macos/plezy.icon/Assets/plezy-cropped.svg b/macos/plezy.icon/Assets/plezy-cropped.svg new file mode 100644 index 00000000..d34219dd --- /dev/null +++ b/macos/plezy.icon/Assets/plezy-cropped.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/macos/plezy.icon/icon.json b/macos/plezy.icon/icon.json new file mode 100644 index 00000000..3af70ecd --- /dev/null +++ b/macos/plezy.icon/icon.json @@ -0,0 +1,55 @@ +{ + "fill" : { + "solid" : "gray:0.94630,1.00000" + }, + "groups" : [ + { + "blur-material" : null, + "layers" : [ + { + "blend-mode-specializations" : [ + { + "appearance" : "dark", + "value" : "normal" + } + ], + "fill-specializations" : [ + { + "value" : "none" + }, + { + "appearance" : "dark", + "value" : "none" + } + ], + "glass" : true, + "hidden" : false, + "image-name" : "plezy-cropped.svg", + "name" : "plezy-cropped", + "position" : { + "scale" : 2, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 00000000..f9a39466 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1179 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a + url: "https://pub.dev" + source: hosted + version: "88.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" + url: "https://pub.dev" + source: hosted + version: "8.1.1" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 + url: "https://pub.dev" + source: hosted + version: "4.0.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "4e54dbeefdc70691ba80b3bce3976af63b5425c8c07dface348dfee664a0edc1" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://pub.dev" + source: hosted + version: "8.12.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + dart_service_protocol_shared: + dependency: transitive + description: + name: dart_service_protocol_shared + sha256: "1737875c176d7e3d87bb3a359182828b542fe20a0b34198b8d31a81af5c7a76d" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + dtd: + dependency: "direct main" + description: + name: dtd + sha256: "09ddb228b3d1478a093556357692a4c203ff4f9d5f8cda05dfdca0ff3fb7c5d3" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: "direct main" + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + sha256: "3c46c2633aec07810c3d6a2eb08d575b5b4072980db08f1344e66aeb53d6e4a7" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + url: "https://pub.dev" + source: hosted + version: "6.11.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + macos_window_utils: + dependency: "direct main" + description: + name: macos_window_utils + sha256: d4df3501fd32ac0d2d7590cb6a8e4758337d061c8fa0db816fdd636be63a8438 + url: "https://pub.dev" + source: hosted + version: "1.9.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + media_kit: + dependency: "direct main" + description: + path: media_kit + ref: HEAD + resolved-ref: "5d002a6bd9c4b4247f83f1c0719f8144daf87f0f" + url: "https://github.com/edde746/media-kit" + source: git + version: "1.2.1" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 + url: "https://pub.dev" + source: hosted + version: "2.2.20" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: e9a21b6fec7a8aa62cc2585ff4c1b127df42f3185adbd2aca66b47abe2e80236 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + screen_brightness_android: + dependency: transitive + description: + name: screen_brightness_android + sha256: d34f5321abd03bc3474f4c381f53d189117eba0b039eac1916aa92cca5fd0a96 + url: "https://pub.dev" + source: hosted + version: "2.1.3" + screen_brightness_platform_interface: + dependency: transitive + description: + name: screen_brightness_platform_interface + sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unified_analytics: + dependency: transitive + description: + name: unified_analytics + sha256: "8d1429a4b27320a9c4fc854287d18c8fde1549bf622165c5837202a9f370b53d" + url: "https://pub.dev" + source: hosted + version: "8.0.5" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: ff4d2c720aca3f4f7d5445e23b11b2d15ef8af5ddce5164643f38ff962dcb270 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" + url: "https://pub.dev" + source: hosted + version: "6.3.24" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" + url: "https://pub.dev" + source: hosted + version: "6.3.5" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" + url: "https://pub.dev" + source: hosted + version: "3.2.4" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + volume_controller: + dependency: transitive + description: + name: volume_controller + sha256: d75039e69c0d90e7810bfd47e3eedf29ff8543ea7a10392792e81f9bded7edf5 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" + url: "https://pub.dev" + source: hosted + version: "0.4.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: "direct main" + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..284c66c0 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,58 @@ +name: plezy +description: "A beautiful Plex client for Flutter" +publish_to: "none" +version: 0.1.0 + +environment: + sdk: ^3.8.1 + +dependencies: + flutter: + sdk: flutter + http: ^1.2.0 + dio: ^5.4.0 + json_annotation: ^4.8.1 + xml: ^6.5.0 + shared_preferences: ^2.2.2 + dtd: ^4.0.0 + cached_network_image: ^3.4.1 + media_kit: ^1.1.10+1 + media_kit_video: ^1.2.4 + media_kit_libs_video: ^1.0.4 + macos_window_utils: ^1.9.0 + url_launcher: ^6.3.0 + uuid: ^4.4.0 + window_manager: ^0.4.3 + logger: ^2.0.2 + +dependency_overrides: + media_kit: + git: + url: https://github.com/edde746/media-kit + path: media_kit + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + build_runner: ^2.4.7 + json_serializable: ^6.7.1 + flutter_launcher_icons: ^0.13.1 + +flutter: + uses-material-design: true + assets: + - lib/data/iso_639_codes.json + +flutter_launcher_icons: + android: true + ios: false + image_path: "assets/plezy.png" + adaptive_icon_background: "#ffffff" + adaptive_icon_foreground: "assets/plezy_android_foreground.png" + macos: + generate: false + windows: + generate: true + linux: + generate: true diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 00000000..d492d0d9 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 00000000..3d42510d --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(plezy LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "plezy") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/build-installer.ps1 b/windows/build-installer.ps1 new file mode 100644 index 00000000..ef5371b4 --- /dev/null +++ b/windows/build-installer.ps1 @@ -0,0 +1,123 @@ +#!/usr/bin/env pwsh +# Windows Installer Build Script +# This script creates both a portable archive and an installer for the Windows build + +param( + [string]$OutputDir = ".", + [string]$Version = "1.0.0" +) + +$ErrorActionPreference = "Stop" + +Write-Host "Building Windows installer packages..." -ForegroundColor Cyan + +# Ensure we're in the project root +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectRoot = Split-Path -Parent $ScriptDir +Set-Location $ProjectRoot + +# Define paths +$BuildDir = "build\windows\x64\runner\Release" +$PortableZip = Join-Path $OutputDir "plezy-windows-portable.zip" +$InstallerExe = Join-Path $OutputDir "plezy-windows-installer.exe" +$SetupScript = "setup.iss" + +# Check if build exists +if (-not (Test-Path $BuildDir)) { + Write-Error "Build directory not found at $BuildDir. Please run 'flutter build windows --release' first." + exit 1 +} + +Write-Host "Found build at: $BuildDir" -ForegroundColor Green + +# Create Portable Archive +Write-Host "`nCreating portable archive..." -ForegroundColor Cyan +Push-Location $BuildDir +try { + if (Test-Path $PortableZip) { + Remove-Item $PortableZip -Force + } + Compress-Archive -Path * -DestinationPath $PortableZip + Write-Host "Created: $PortableZip" -ForegroundColor Green +} finally { + Pop-Location +} + +# Create Inno Setup Script +Write-Host "`nGenerating Inno Setup script..." -ForegroundColor Cyan +@" +#define Name "Plezy" +#define Version "$Version" +#define Publisher "edde746" +#define ExeName "plezy.exe" + +[Setup] +AppId={{4213385e-f7be-4f2b-95f9-54082a28bb8f} +AppName={#Name} +AppVersion={#Version} +AppPublisher={#Publisher} +DefaultDirName={autopf}\{#Name} +DefaultGroupName={#Name} +AllowNoIcons=yes +OutputDir=. +OutputBaseFilename=plezy-windows-installer +Compression=lzma +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\{#Name}"; Filename: "{app}\{#ExeName}" +Name: "{group}\{cm:UninstallProgram,{#Name}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#Name}"; Filename: "{app}\{#ExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#ExeName}"; Description: "{cm:LaunchProgram,{#Name}}"; Flags: nowait postinstall skipifsilent +"@ | Out-File -FilePath $SetupScript -Encoding ASCII + +Write-Host "Created: $SetupScript" -ForegroundColor Green + +# Check for Inno Setup +Write-Host "`nChecking for Inno Setup..." -ForegroundColor Cyan +$InnoSetupPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + +if (-not (Test-Path $InnoSetupPath)) { + Write-Host "Inno Setup not found. Installing via Chocolatey..." -ForegroundColor Yellow + + # Check if choco is available + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Error "Chocolatey is not installed. Please install it from https://chocolatey.org/install" + exit 1 + } + + choco install innosetup -y + + if (-not (Test-Path $InnoSetupPath)) { + Write-Error "Failed to install Inno Setup" + exit 1 + } +} + +# Build Installer +Write-Host "`nBuilding installer with Inno Setup..." -ForegroundColor Cyan +& $InnoSetupPath $SetupScript + +if ($LASTEXITCODE -ne 0) { + Write-Error "Inno Setup compilation failed" + exit 1 +} + +Write-Host "`nBuild complete!" -ForegroundColor Green +Write-Host "Portable archive: $PortableZip" -ForegroundColor White +Write-Host "Installer: $InstallerExe" -ForegroundColor White diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..903f4899 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..122493b4 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,29 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + VolumeControllerPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..dc139d85 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..a6d435b3 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + media_kit_libs_windows_video + media_kit_video + screen_retriever_windows + url_launcher_windows + volume_controller + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..394917c0 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 00000000..6947dcae --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "Plezy" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "Plezy" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "Plezy.exe" "\0" + VALUE "ProductName", "Plezy" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..955ee303 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 00000000..6da0652f --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 00000000..7b4fcdaa --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"Plezy", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 00000000..bb4b6c51 Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..153653e8 --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 00000000..3a0b4651 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_