diff --git a/script.plexmod/.gitignore b/script.plexmod/.gitignore new file mode 100644 index 000000000..59f60a7f6 --- /dev/null +++ b/script.plexmod/.gitignore @@ -0,0 +1,3 @@ +/fixRepo.py +/resources/skins/Main/TexturePacker.exe +/resources/skins/Main/packtex.bat diff --git a/script.plexmod/LICENSE.txt b/script.plexmod/LICENSE.txt new file mode 100644 index 000000000..6bcffb461 --- /dev/null +++ b/script.plexmod/LICENSE.txt @@ -0,0 +1,492 @@ + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) 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 +this service 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 make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. 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. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the 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 a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE 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. + + END OF TERMS AND CONDITIONS +------------------------------------------------------------------------- + + +Some of this code has been modified from the PlexApi written by Michael Shepanski + +https://github.com/mjs7231/python-plexapi + +The license for that code follows: + +Copyright (c) 2010, Michael Shepanski +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name conky-pkmeter nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------- + +https://github.com/ValentinBELYN/icmplib + +GNU LESSER 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. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser 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 +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. + + +------------------------------------------------------------------------- +Fontawesome +https://fontawesome.com/license/free +CC BY 4.0 License: https://creativecommons.org/licenses/by/4.0/# diff --git a/script.plexmod/README.md b/script.plexmod/README.md new file mode 100644 index 000000000..317bd7cab --- /dev/null +++ b/script.plexmod/README.md @@ -0,0 +1,33 @@ +# PlexMod (for Kodi) + +This is a modification of the official open-source Plex client for Kodi "plex-for-kodi" (Plex4Kodi) semi-maintained by me (pannal). + +Contrary to how this repository was handled before, this client does _not_ claim to adhere to the Plex Inc. design guidelines, all the time. + +It implements features that are not implemented in other official Plex clients and may implement others in non-conform ways. + +It is still based off of the original P4K source and critical bugfixes will be PR'd back. + +## Active branches +* [develop-kodi21](https://github.com/pannal/plex-for-kodi/tree/develop_kodi21) (Kodi 19, 20, 21 cross-compatible) +* [develop-kodi18](https://github.com/pannal/plex-for-kodi/tree/develop_kodi18) (legacy) + +Master branch is based off of the official plex-for-kodi master branch. + +## Installation + +### Via repository +* Add `https://pannal.github.io/dontpanickodi/` to your Kodi installation as a file source +* Go back to addons, choose zip file, choose the file source you added and install the repository +* Install Plex via Addons->Install from repository->Don’t Panic->Video add-ons->Plex +* Optional, recommended: Install Plextuary via Addons->Install from repository->Don’t Panic->Look and Feel->Skin->Plextuary + +### Manual +* Checkout any branch of this GitHub repository, rename to `script.plexmod` and use as an addon + + +## Help/Bug Reports +https://forums.plex.tv/t/plexmod-for-kodi-18-19-20-21/481208 + +## License +[LICENSE](https://github.com/plexinc/plex-for-kodi/blob/master/LICENSE.txt) diff --git a/script.plexmod/TESTING_SQUAD.md b/script.plexmod/TESTING_SQUAD.md new file mode 100644 index 000000000..12506a8e3 --- /dev/null +++ b/script.plexmod/TESTING_SQUAD.md @@ -0,0 +1,2 @@ +## The Squad (TM) +@sonofdibnah @ecsjjgg @Unknown3899 @THGhost @bowlingbeeg @Buttzy10169 diff --git a/script.plexmod/addon.xml b/script.plexmod/addon.xml new file mode 100644 index 000000000..a3f7eb8ca --- /dev/null +++ b/script.plexmod/addon.xml @@ -0,0 +1,53 @@ + + + + + + + + + + video + + + executable + + + + PlexMod for Kodi + Unofficial Plex for Kodi add-on + GPL-2.0-only + https://forums.plex.tv/t/plexmod-for-kodi-18-19-20-21/481208 + https://www.plex.tv + https://github.com/pannal/plex-for-kodi + all + +- Fix: playing next episode when episode played threshold was met when hitting STOP button +- Fix: Dropdown would roundrobin falsely onup item when initial item wasn't item 0 +- Fix: Movies: pressing back in a scrolled movie view with the header options open wouldn't back out properly +- Fix: Reset library filters when Role/Cast view opened +- Fix: Cast display in movies/preplay +- Fix: Apply dialog flicker fix in Role/Cast +- Fix: Dropdowns were broken sometimes (especially library view) +- Fix: Addon crash on non-default Kodi (e.g. OSMC) due to System.BuildVersion not matching Kodi's default +- Fix: Error on no data returned on PlexObject.reload (disconnect); Handle connection errors more gracefully +- Fix: Background of info page (thanks @bowlingbeeg) +- Fix: Rare SeekDialog crashes between episodes +- Fix: OpenSubtitles not finding subtitles for certain Movies + +- Core: Harden against network disconnects in multiple views, home and videoplayer/seekdialog +- Core: Re-enable backing out of videoplayer (inbetween screen) +- Core: Player: Alternative implementation of bingeMode auto-next; ignore stop events when triggering non-user-stop +- Core: Add setting for poster/thumbnail resolution scaling, implement everywhere sensible +- Core: Thumbnails/Art: use the same parameters as PlexWeb for images +- SeekDialog: Possible fix for OSD not coming up again in some scenarios + + + icon.png + fanart.jpg + + + diff --git a/script.plexmod/changelog.txt b/script.plexmod/changelog.txt new file mode 100644 index 000000000..29648c568 --- /dev/null +++ b/script.plexmod/changelog.txt @@ -0,0 +1,637 @@ +[- 0.7.2 -] +- Fix: playing next episode when episode played threshold was met when hitting STOP button +- Fix: Dropdown would roundrobin falsely onup item when initial item wasn't item 0 +- Fix: Movies: pressing back in a scrolled movie view with the header options open wouldn't back out properly +- Fix: Reset library filters when Role/Cast view opened +- Fix: Cast display in movies/preplay +- Fix: Apply dialog flicker fix in Role/Cast +- Fix: Dropdowns were broken sometimes (especially library view) +- Fix: Addon crash on non-default Kodi (e.g. OSMC) due to System.BuildVersion not matching Kodi's default +- Fix: Error on no data returned on PlexObject.reload (disconnect); Handle connection errors more gracefully +- Fix: Background of info page (thanks @bowlingbeeg) +- Fix: Rare SeekDialog crashes between episodes +- Fix: OpenSubtitles not finding subtitles for certain Movies + +- Core: Harden against network disconnects in multiple views, home and videoplayer/seekdialog +- Core: Re-enable backing out of videoplayer (inbetween screen) +- Core: Player: Alternative implementation of bingeMode auto-next; ignore stop events when triggering non-user-stop +- Core: Add setting for poster/thumbnail resolution scaling, implement everywhere sensible +- Core: Thumbnails/Art: use the same parameters as PlexWeb for images +- SeekDialog: Possible fix for OSD not coming up again in some scenarios + + +[- 0.7.1 -] +- Fix: Embedded subtitle could be set to the wrong one (edge case) +- Fix: Episodes: theme music error when clicking on seasons +- Possibly fix rare traceback when closing an episodes window while waiting for an episode reload task +- Fix: Core: only stop playback on screensaver if player is playing video (not audio, ...) +- Fix: rare UI crash for items before 1970-01-02 (yes, we've had media back then) + +- Set Accept-Language to Kodi's language setting when talking to the Plex Server, localizes subtitle titles, Library translations etc. +- Use extended title for subtitles (and add advanced/addon setting) +- Add: Show buffer state in stream info +- Reduce dialog flickering in certain situations (not fully, probably impossible) +- Player: bingeMode/autoskip credits/manual-next: avoid double-next in certain circumstances +- VideoPlayer: don't react to inputs while waiting for playback (AVStarted) to start (fixes early-back-out-crashes) +- SeekDialog: stop marker countdown on seek +- SeekDialog: auto-skip marker: ignore input while auto skipping to next video +- SeekDialog: only count down marker when OSD not shown +- SeekDialog: ignore input while handling next-video-pressed +- SeekDialog: hide OSD fast on ACTION_PAUSE/ACTION_PLAYER_PLAY/ACTION_PLAYER_PLAYPAUSE and OSD is shown +- Player/SeekDialog: Fix crash when handling SIGINT (alt+f4 on windows) +- Hubs: make all TV/movie hubs as hub types that receive updates after items being watched or marked (un) watched +- Settings: Disable alternative home hubs refresh by default +- ServerList: further hardening, list shouldn't visually "crash" anymore in certain conditions +- Pagination/Episodes: allow right round-robin on paginated long item lists (>26) +- Movies: Reorder Cast/Related/Extras according to PlexWeb +- Movies: Add reviews +- Cast: Increase Cast picture quality everywhere +- Core/Settings: Adjust recommended cache/buffer/readfactor values for different Kodi versions, starting with 21.0-BETA2 (20.90.821) +- Core/Settings: Incorporate new Kodi cache/readfactor values; clamp cache values to divisible by 16 +- Serverlist: allow roundrobin at the bottom boundary +- StreamInfo: hide OSD after closing streaminfo +- Settings: Add setting to use Kodi keyboard for searching (default: off) +- Main: harden main loop (still not exiting properly when exiting fast after server change; reinits previously inited windows on exit, sometimes) +- SubtitleDownload/OpenSubtitles.com: set global videoinfo infoLabels to improve subtitle search hinting (waiting for PR to be accepted) + + +[- 0.7.0 -] +- Fix kiosk mode startup issue when other modal dialogs were active when PM4K was trying to start in kiosk mode +- Fix video OSD hiding too fast in certain circumstances +- Fix photoplayer issues; skip ugly initial loading image; show loading state only when it takes longer than 500ms; fix prev/next not always working; increase speed by x10 +- Fix episodes view replaying the theme music when returning from a different-season-view +- Fix mediaBufferSize reported to the Plex MDE endpoint was always empty (resulting in Plex assuming we only have 5 MB cache, leading to all sorts of buffer issues) +- Fix pin entry with J characters looking like an uppercase i +- Fix episode regularly ending in bingeMode (not credits skipped) still showing post play +- Fix enabled subtitles always leading to a transcode (and subtitle burn-in) of the video stream in DirectStream scenarios +- Fix player UI for transcoded/DirectStream sessions + +- UI: background/transition/rendering core rework, reducing flickering, "bouncy" background fades, improving overall snappiness of the interface (dialog transition flicker not solved) +- UI: remove the [...]-loading-spinner for everything that takes under 500ms, making the UI more natural +- UI: crossfade backgrounds before entering a view, if possible, reducing the animation load between view transitions +- UI: support more elements as background-providers (e.g. photos) +- Add advanced/plugin setting to enable/disable background crossfading altogether +- Add setting to stop video playback when screensaver (Kodi) is activated +- Add setting to stop video playback after a set idle time +- Increase home section change timeout from 0.3s to 0.5s and allow immediate section selection using ENTER + +- Completely rework the AC3 handling, allowing to select in which channel configurations one wants to transcode to AC3 +- Allow subtitles to be DirectStreamed; resulting in possibly ONLY the audio being transcoded +- Add setting to burn in SSA/ASS subtitles when DirectStreaming. When disabled, they're converted to raw text and DirectStreamed, if enabled (previous default), they lead to the video stream being transcoded +- Enable markers (intro/credits) functionality in transcoded/DirectStream sessions +- Harden chapter selection logic in player UI +- SeekDialog: Unify time formatting as much as possible between transcoded and directplay modes +- Add setting to allow or deny auto-skip functionality when transcoding/DirectStreaming +- Movies preplay: raise the ratings up by 70 pixels to be in line with the title, fixes issues with certain skins +- Core: improve skin compatibility: font30 is no more as we're not using Confluence but Estuary as default. Replace with its fallback, font13 +- Core: improve skin compatibility: font16 is no more as we're not using Confluence but Estuary as default. Replace with its fallback, font13 +- Core: allow DirectStreaming of webvtt subtitles in Kodi >= 20 +- AdvancedSettings: reset background blur and opacity to their new defaults +- BGM: store last "good" audio volume before playing theme music, and apply it upon start of the addon, in case the volume reset after BGM failed/was skipped +- Core: Harden and speed up server change + + +[- 0.6.5+rev5 -] +- Add support for urllib3 >= 2.1.0 + + +[- 0.6.5+rev4 -] +- Fix dropdown roundrobin needing two UPs if first selected item is item 0, introduced in 0.6.5 +- Theme music: Harden BGM handler; wait for correct volume to be re-set, might fix not-so-rare stale volume issue +- Episodes: Show TV Show's other seasons in episodes window + + +[- 0.6.5 -] +- Fix rare postplay still shown on bingemode/autoskip credits +- Fix quick subtitle selection when override forced advanced setting is active +- Fix rare round robin case skipping item 0 onup in dropdowns +- Fix episode progress and time remaining not updating (thanks @bowlingbeeg) +- Fix embedded subtitle display delay when Cycling/using buttons to switch subtitles +- Fix rare subtitles not shown issue + +- Add: Allow video player UI buttons customization via settings; remove a couple of player navbar buttons by default +- Add "Previous Subtitle" to quick subtitles nav item +- Remember subtitle actions (prev, next, download, delay) via quick nav +- Use our own CycleSubtitles/ShowSubtitles implementation; +- Rely even less on Kodi's Player states for subtitles +- Parse and show SDH/HI flag for subtitle streams +- Hide OSD faster after closing settings; try hiding OSD faster in general if necessary +- Lose orange fallback BG on userselect as well by default +- Settings: rename Player to Player UI, rename Player (user-specific) to Playback (user-specific) +- Settings: Move forced subtitles fix from plugin settings to settings interface audio/subtitles +- Store last background URL in settings if dynamic backgrounds is active; use last BG url on startup to prevent blank BG +- Reduce embedded subtitle display backseek to 100ms +- Use episode thumbnails in continue hub if available, by default; add advanced setting +- Set default background blur to 0 (was 4), background opacity to 20 (was 30) +- Disable legacy background image fallback by default; add advanced setting +- Core improvements + + +[- 0.6.4 -] +- Fix preplay mediachoice issues +- Fix seeking with auto-skip scenarios on slower devices +- Fix online/trailer quality issues (was always using a low quality before) +- Fix round-robining in episode view +- Fix Kodi 18 +- Fix some rare Kodi playback errors in logs, mostly cosmetic +- Fix player sometimes not sending the last timeline state after playback ends +- Fix playback issues when multiple versions exist but one of them is missing +- Fix embedded subtitle display delays on playback start and on subtitle change while playing back (backseeks 1 second) +- Fix double-back-button necessary to exit when cancelling autoskip +- Fix chapters not showing when no markers present +- Fix User Pin entry not showing errors +- Fix reactivating addon after minimize +- Fix autoskip marker continuing to count down when paused; don't hide autoskip marker when paused + +- Add support for multiple media versions and fix individual stream selection support +- Add support for stream selection in trailers/extras +- Add current cache/buffer state to progress bar +- Add video/audio/subtitle stream selection for trailers +- Add zero drift playback timers for current time, time left, ends at +- Add minimize option to exit +- Add playback settings manager to enable full settings per TV show instead of just binge mode toggle; +- Add slow connection support (e.g. in a hotel); waits for the buffer to fill to a reasonable amount until playback; sets readfactor to 20 (don't use with passthrough) +Migrate previous binge mode data to new extensible format, stored as addon_data/playback_settings.json + +- Player: Better/more immediate intro-auto-skip implementation +- Cache/Buffer: Allow 26% and 30% "overcommit" (+android 23%) +- Cache/Buffer: Add readfactor to settings (needs Kodi restart) +- Cache/Buffer: Add support for new kodi-omega-master Cache/Buffer GUI settings +- Harden next/prev episode handling in auto-skip scenarios +- Harden next/prev button handling +- Settings: allow vertical round-robining for settings and options lists +- User Switch: Allow cancellation of switch; select the current user by default when switching + + +[- 0.6.2 -] +- Add system setting to execute action upon sleep event (e.g. exit Kodi when display goes to sleep; fixes passthrough issues on SHIELD) +- Allow canceling postplay timeout with enter/OK +- Binge mode: show postplay if video was exited manually +- Show local/LAN server status in status bar and server list via new icon; server list is live now +- Discover local/LAN servers automatically (if any of the plex.tv/resource's DNS points to a local IP and is reachable) +- plexapp/account/main: init account earlier to pick up ID early; slightly reorder initialization sequence to pick up preferred servers earlier +- Connection logic: refactor connection significance; wait for manual connections if necessary/wanted; add way more logging (as this might break things) +- Remember last used server per user +- Refactor seekdialog autoskip handling and chapter/marker display logic +- Harden the episodeswindow's paginator, making it less easy to generate a huge amount of requests when paging quickly (holding down left/right) +- Harden chapter/marker autoskip +- Add "combined chapters", intelligently merging chapters and markers into one, if available +- Add BIF preview images to markers if available ("Enable video preview thumbnails" needs to be enabled on server and library) +- Add separate "show autoskip info early" offset to addon settings (default: 2.5s) +- Add countdown to autoskip info button, informing the user that we're about to auto skip the next marker (intro/credits) +- Set skip intro button show early threshold to 60s (was 120s) to skip recaps but only those +- Seek as fast as possible on immediately occurring intro; skip directly to next video if possible on last credits with autoskip enabled +- Add separate connectivity check timeout; properly set up timeouts for async requests; log ping in local network tests +- Harden server discovery; further improvements to preferring local over secure +- Update reachability of servers live in server list; +- Remove server refresh button due to issues; +- Add fontawesome license +- Show busy dialog when selecting a new user, preventing errors +- Trigger force available servers/connections refresh when changing network settings; + +- Fix Bingemode not properly stored per user +- Fix multiple credits naming in seekdialog +- Fix error upon episodes window reinit; catch error upon missing listitem +- Remove time left tag after watching more than one episode +- Add some missing advanced/addon setting descriptions; cleanup +- Disable GDM discovery by default +- Fix late display of embedded subtitles; the change has been forgotten, sorry; fixes #54 + + +[- 0.6.0-rc1 -] +- Add Kodi Buffer/Cache settings to new System tab +- Add pm4k_cache_template.xml to allow further customization (copy to profile folder) +- Add addon path and Kodi profile path to Settings/About +- Add advanced/addon setting to set (Plex) HTTP request timeouts; change default from 10 to 5 (on display/crash issues, increase timeout) +- Add explicit LAN check for servers (docker-based ones won't show as local by default); only works on Kodi 19 and above; add icmplib and license +- Add setting to specify LAN check connection timeout (default 10ms) +- If desired, prefer an insecure local server over a secure one after switching users, as well +- Add warning when preferring local server connections over secure ones +- Add long timeout for essential plex.tv requests +- Add force server list refresh button to server list (allows reloading the available servers after changing PMS network settings) +- Add setting to verify local/LAN connections even if plex.tv doesn't think they're local +- Add setting to prefer local connections over secure ones, enabling enforced manual servers +- Add advanced/addon setting to add an offset to intro/credits autoskip, as the Plex markers might be a little early (default +2 seconds) + +- Show video chapters if available in playback instead of bigSeek (also add a setting) +- Show virtual chapters in playback (from Plex markers, intro, credits etc.) if no chapters exist for a video file +- Reload items properly with chapters upon preplay-reinit +- Reorder settings, advanced is now system, add network settings +- Show chapters and/or markers in info view of a video item + +- Implement TV binge mode (auto skip intros if not first EP of season; auto skip credits; skip postplay) +- Implement per-user settings +- Make Binge-mode overridable per show +- Make Binge-mode, all auto-skip and whether to display chapters or markers a per-user setting + +- Fix focusing the wrong episode after opening the info screen +- Fix reloading episodes list after watching multiple episodes +- Strip out MP4 file name in stream URL to prevent subtitles trying to be downloaded, fixing long wait time (thanks @microadam) +- Fix failure on video with no audio stream +- Optimize remaining time calculations for preplay screen + + +[- 0.5.5-rev2 -] +- Fix auto intro skip not working after skipping to next episode +- Skip postplay when skipping to next video from player +- Limit early-show-intro-skip to intros occurring in the first 2 minutes; +- add advanced setting to customize this value +- Add option to show and episodes-windows (they toggle their show) to override the global auto-skip-intro setting for a show + + +[- 0.5.5 -] +- Add main setting to show the intro skip button early; enables recap skipping; works with auto-intro-skip! +- Move some boolean settings from Main>Advanced to Main +- Add main setting to use alternative home hubs refresh method introduced by @bowlingbeeg; default on +- Add main setting to specify item-played-threshold in main settings, please sync with your Plex Server; possibly fixes edge cases (postplay/next-overskip issues) +- Fix postplay timer-circle display on later (Nexus++?) Kodi versions +- Rework time formatting and hour padding detection for "Ends at" display completely +- Change watched status to also look at resume offset (thanks @bowlingbeeg) +- Fix crash when background threads are still running on library exit (thanks @bowlingbeeg) +- Fix playlist issues (thanks @bowlingbeeg) +- Change look of letter jump list (thanks @bowlingbeeg) +- Fix sorting/filtering issues (thanks @bowlingbeeg) +- Add second view type for episodes and albums (thanks @bowlingbeeg) +- Support showing collections inline (thanks @bowlingbeeg) +- Add support for smaller poster size view (thanks @bowlingbeeg) + + +[- 0.5.4 -] +- Store user thumbnail in cache (improves home "load" time) +- Change client identifier from Plex-for-Kodi to PM4K; + possibly fix "PlexNet" entries in Authorized Devices; might also break stuff, please report back +- fix closing quote in chinese translation +- Add missing German translations +- Add "Ends at" to player ends-at time by default; add option to disable this label +- Add fallback for missing audiooutput.channels setting (seems to affect libreelec only); relates to #48 +- Change resume dialog to show resume time (thanks @bowlingbeeg) +- Add unwatched status to pre-play screen and add progress bars to more hubs (thanks @bowlingbeeg) +- Fix missing parameters on Plex API queries (thanks @bowlingbeeg) +- Add time left to pre-play screen (thanks @bowlingbeeg) +- Fix audio/subtitle selection not updating correctly in pre-play screen (thanks @bowlingbeeg) + + +[- 0.5.3 -] +- Inhibit screensaver when in photo slideshow (Kodi 19 and above) +- Add addon setting to use old profile from P4K instead of the optimized one (might fix 3D issues with DP; not sure why?); default: off +- Show video stream render type in preplay/epidoses screen (SDR/HDR/DV/HLG) +- Adjust default background image blur/opacity from 0/40 to 4/30 +- Add addon setting to set solid background colour instead of pictures (Kodi 19 and above) +- Add addon setting to hide when current media will end (default: shown, as before) +- Split filename in info screen to avoid overflow +- Don't show "no content" on home; amended PR #43 +- Bad temporary fix for photo playqueues containing videos +- Photos: Use Kodi slideshow interval; fixes #41 +- Add Simplified Chinese Translation (thanks @Liqianyu) +- Fix AV1 setting description +- Fix single-season pagination (anime) +- Update german translation (thanks @jamal2362) +- Fix issue when displaying a library that has no hubs to display (thanks @bowlingbeeg) +- Fixed an issue with video playlists and the general play button not working (thanks @bowlingbeeg) +- Fixed a couple of places that require integer division (thanks @bowlingbeeg) + + +[- 0.5.2-beta0.1 -] +re-add support for leia (Kodi 18) + + +[- 0.5.1 -] +add media/part/stream info to info screen +read and honor advancedsettings/memorysize + + +[- 0.5.0 -] +implement multiple markers per video file +add skip credits Plex-style (skip to post-credit scene if applicable or skip to end) +add auto skip credits feature +actually respect DirectPlay=Off and DirectStream=Off +fix directstream/transcoding (DS never worked apart from h264) +fix transcoded audio always being MP3 +deviate from Web-client-based profile to own dynamic profile +allow directplay/stream/transcode of virtually anything on Kodi +add settings to force directstream/transcoded audio to AC3 and/or DTS (for passthrough) +add setting to limit directstream/transcoded audio to original audio bitrate/channels +add setting to limit directstream/transcoded audio to Kodi audio channel setting +prevent PMS transcoding audio to higher channel count than original audio +show video codec in preplay screen +settings: round-robin when at the end of the left heading list +stream status dialog: show HW encoding state +replace progressive jpg files (thanks @shyzus) + +note: massive thanks to @hbbs for reporting the MP3 issue and sticking with me for the whole process of figuring out how to do audio stuff in a Plex client + + +[- 0.4.6 -] +add more sort options for ratings where applicable (rating, audienceRating, contentRating, userRating) +add smart desc/asc default sort (need feedback) +use PlexWeb sort field naming (except for PlexWeb Rating=My Rating which is clearer) +note: TV shows "By Audience Rating" seems to be broken in Plex +previous beta fixes +small bugfixes + + +[- 0.4.5-beta1.3 -] +properly refocus after stopping music via the mini player fix msgstr +reset autoseektimer prematurely before auto skipping intro +stream details: don't show unknown values +some music fixes; internals +auto intro skip: skip only once, show manual skipping otherwise, measures for preventing wrong jump +fix HEVC default + + +[- 0.4.5-beta1.2 -] +fix music and photo handlers (thanks @Buttzy10169) +fix next/prev handlers in general +fix previous release's issues +use different photos temp path resolution +enable HEVC by default + + +[- 0.4.4 -] +community fixes (thanks @Buttzy10169 @fvlaicu) +add auto-skip intro setting (default: off) +add addon-setting to customize how long skip-intro button is shown (default: 10s) + + +[- 0.4.3 -] +Allow skip-intro for users on a plexpass enabled server (thanks @fvlaicu) +Fix visual issues (due to botched addon rename) +Make AV1 setting Kodi major version dependant; pave way for hybrid codebase for 19/20 +Implement new network discovery mech on windows (GDM) using powershell (experimental) +Possibly fix GDM on windows (especially on non-english variants) +Possibly fix GDM on Linux (non-BSD) + + +[- 0.4.2 -] +Fix skip intro button +Fix duplicate addon setting +Enable dynamic backgrounds by default set defaults for dynamic backgrounds: blur=0, opacity=40 +Fix kiosk mode (autorun plex on kodi startup) +Fix music playback +Fix theme music playback; set default theme music volume to 50% + + +[- 0.4.1 -] +Initial PlexMod for Kodi release, based on my plex-for-kodi fork's develop branch +Add support for Kodi Nexus (20) on LibreELEC and Android +Add AV1 toggle and AV1 direct play support +Rename addon to "script.plexmod" +Add all non-plex-conform features of the develop branch as default features + +[- 0.2.3 -] + +Fix error causing playback to fail + +[- 0.2.2 -] + +Added support for collections in library views +Update Polish strings (thanks to Zachar) +Fix GDM issue (thanks to MrPumo) +Fix Python 3 issues (thanks to MrPumo and pannal) + +[- 0.2.1 -] + +Added Skip Intro (thanks to pannal) +Use device name for friendly name +Fix some hubs not displaying +Added title scrolling when focused on some items +Show playback speed on OSD +Fix possible error on playlists window + +[- 0.2.0 -] + +Bump version to 0.2.0 to differentiate from deprecated Kodi 17 0.1.x versions + +[- 0.1.8 -] + +Fix seek resetting when clicking select on seek bar with OSD visible +Add Spanish translations (thanks to rdcalle) +Add Portuguese (Portugal) translations (thanks to Generator) +Make python 3 compatible (thanks to pannal) + +[- 0.1.7 -] + +Fix transcode failures on Windows (possibly other platforms) +Add Italian translations (thanks to iz8mbw) +Use Kodi time format setting (Issue #90) (thanks to pannal) +Restore last window when starting the add-on while it is running (Issue #126) +Add Portuguese (Brazil) translations (thanks to DiogoAbu) +Add ability to exit search edit box with previous menu (escape) button (Issue #194) (thanks to pannal) +Only play unwatched media when pressing play or shuffle and filtering for unwatched (thanks to tlt21) +Fix issue with duplicated icons when fast-scrolling a hub (Issue #177) (thanks to pannal) +Fixed GUI not responding after public IP renewal (thanks to pannal and mkliche) +Advanced setting: Automatically seek selected position after a delay (Issue #172) (thanks to pannal) +Advanced setting: Use skip step settings from Kodi (Issue #133) (thanks to pannal) +Advanced setting: Use Plex/Kodi steps for timeline (Issue #133) (thanks to pannal) +OSD-Autohide improvements (thanks to pannal) +Show post play on user stop when item is considered played (thanks to pannal) +Use Unplayed/Played instead of Unwatched/Watched (thanks to pannal) +Fix issue with cast popup displaying partially offscreen (thanks to pannal) +Various fixes for incorrectly transcoded playback (Issue #201) (thanks to pannal) +Support new subtitles menu in playback for Leia (thanks to pannal) +Make sure progress bar is visible on items with less than 2% watched (thanks to pannal) +Fix focus issues caused when hubs are removed, i.e. On Deck, Continue Watching (thanks to pannal) +Fix issues with flattened seasons +Allow Kodi info display to be visible when hitting the info button +Fixes hubs not updating and some crash issues +More fixes for Kodi 18, ex. missing letters list (Issue #265) +Fix resume behavior with DSPlayer (thanks to pannal) +Add Polish translations (thanks to Zachar2) +Playlist and album view tweaks (thanks to pannal) +Quality selection clean up and fixes (thanks to pannal) +Fix to allow 3D mode selection +Add advanced option to skip the header when going back (thanks to pannal) +Ensure paused state is retained after seeking or changing video settings (thanks to pannal) +Allow closing of the options header in scrolled views using context menu button (thanks to pannal) +Some direct playback fixes (thanks to pannal) +Some shutdown fixes (thanks to pannal) +Show play state indicators to related items in preplays and On Deck in postplay (thanks to pannal) +Fix some resume issues (thanks to pannal) + +[- 0.1.6 -] + +Fix issues when selecting subtitles for users of certain languages (Issue #188) + +[- 0.1.5 -] + +Allow direct playback of episodes and movies using the play button on hub screen (thanks to pannal) +In progress watched items had the 'Mark unwatched' option instead of 'Mark watched' +Fix an issue causing the home window to start slightly scrolled down if it was closed scolled down +Fix an issue causing an error when sort was sort was "BY UNWATCHED" and the filtering "UNWATCHED" (Issue #187) +Fix issue with letter shortcuts sometimes being shown/not shown incorrectly +Fix a bug causing crash and reboot on startup on Raspberry Pi (LibreElec specific?) - Reverts "Fix autostart to work with profiles" + +[- 0.1.4 -] + +Kodi v18: Fix skin compatability issues +Kodi v18: Fix incompatibility with requests module update +Kodi v18: Fix issue with playback not resuming properly when direct playing +Kodi v18: Fix issue with progress bar not moving on music player/music playlist screens +Fix album display on artist screen +Fix bug causing failure to show audio player when clicking the mini player from some screens +Show original artist when available (Issue #143) +Improve centering over label backgrounds on pre-play screens +Fix some spots where tokens could still be logged +Play correct track when playing a multi-disc album (Issue #148) +Improve headers for better identification in 'Now Playing' and future server profile +Updated German language strings (thanks to coffinbearer) +Fix French translations file (thanks to coffinbearer) +Fix some Hungarian translations (thanks to vampywiz17) +Add Russian translations (thanks to shady2k) +Fix autostart to work with profiles (thanks to pannal) +Fix focus loss on home screen due to error (Issue #169) +Fix "No Servers Found" when preferred server is unavailable (Issue #159) +Fix system keyboard not working for search (Kodi 17+)(Issue #165) +Fix issue causing hubs to scroll up when settings were opened +Fix failure to sign in when signed off externally (Issue #110 & #117) + +[- 0.1.3 -] + +Fix incompatibility with requests module update + +[- 0.1.2 -] + +Fix issue with hubs not showing with (newer?) server versions + +[- 0.1.1 -] + +Remove PlexPass disclaimer from add-on info + +[- 0.1.0 -] + +Improved hub vertical scrolling animation +Fix (#87): Switching between managed users causes Kodi to crash +Fix (#119): Occasional crashes when exiting the add-on +Remove PlexPass requirement + +[- 0.0.102 -] + +Handle connection errors on link screen polling +Handle skip next/prev when playing video playlists +Home Screen: Navigating to the 'More' button now causes an update for all movement +Add ability to view episodes/albums in library view +Add German translation (thanks to coffinbearer) +Add Czech translation (thanks to Pavuucek) +Add Hungarian translation (thanks to vampywiz17) +Add French translation (thanks to raffoul/rlimbach) +Fix issue #64 (multi part movies won't playback) +Improve timeline reporting +Friendly name now includes hostname to differentiate Kodi devices +Possible fix for issue #102 +Fix a bug causing an error on server decisions +Fix errors caused by localization on episodes and pre-play screens +Fix some errors caused when no internet + +[- 0.0.101 -] + +Fix (Issue #68): Artist screen options button was showing Mark Unwatched +Fix (Issue #65): When returning from playback episode pre-play did not update the selected episode +Fix: Codec info button ('o') handling on Krypton+ +Remove mkv/hls testing option in add-on Kodi settings +Add Debug Logging option to Advanced Settings +Fix: Post-play after the last item in the playlist was replaying the last item instead of the displayed up next item + +[- 0.0.100 -] + +Fix some stability issues +Fix encoding error on startup +Improve fallbaack background quality +Fix an error causing opening of photo directories to fail +Disable debug logging by default +Make Home hub square items smaller +Improve startup and shutdown times +Fix some server reachability testing bugs and improve performance +Possible fix for crashing when shutting down Kodi while the add-on is running +Add workaround to allow launching from My Add-ons and shortcuts +Fix text cutoff for post-play in Estuary +Prevent the add-on from starting another instance on update +Skip user select in offline mode +Show 'Offline Mode' item in user options when in offline mode - selecting attempts going online + +[- 0.0.99 -] + +Fix: Episodes were not resuming + +[- 0.0.98 -] + +Fix: Was allowing non-Plex-Pass users +Fix: Some videos failed to play with certain audio stream selections +Fix: Some videos failed to play due to a logging error + +[- 0.0.97 -] + +Add 'Play Version...' to the pre-play options (Issue #34) +Added spring loaded playlists to Playlist section on Home screen (Issue #28) +Fix playlist composite images +Fix: Extending a hub again after a section change was starting where the previous extension left off +Handle no content and 404 for section hubs on Home screen (Issue #52) +Handle no content or no content for filter on library screen +Handle videos without duration set +Some Home screen hub loading optimization + +[- 0.0.96 -] + +Remove PIL usage +Use plex.tv instead of my.plexapp.com when requesting pin (Issue #50) + +[- 0.0.95 -] + +Now remembers unwatched filter state and sorting selection on library views (Issue #44) +Fix: Local media was being treated as remote media for playback decisions +Added a dialog to search for when selected playlists are not accessible by the current user (Issue #42) +Fixed font issues on episode/movie pre-plays and settings screens (Issue #45) + +[- 0.0.94 -] + +Fix: Filtering for unwatched movies was causing nothing to be displayed (regression) +Fix (Issue #41): Incorrect strings were being displayed on the episode pre-play delete dialog +Playlists now load in progressively in the background (Issue #35) +Added a dialog on failure to connect when getting sign-in pin (Issue #32) +Change posters view so that two rows are always visible + +[- 0.0.93 -] + +Fix: (Issue #20) Filtering by unwatched still showed watched items +Fix: (Issue #33) Photo directories were failing to open from the Home screen +Fix: (Issue #33) Some photos failed to open from the Home screen +Fix: Disabling the 'Allow HEVC' setting had no effect +Added a busy indicator while photos are loading +Fixes for DVR episodes without episode numbers + +[- 0.0.92 -] + +Official Kodi repo submission +Only change audio streams when necessary + +[- 0.0.91 -] + +Fix (Issue #23): Pressing a play on a show was not starting on the first unwatched or in progress episode +Implement (Issue #30): Decision endpoint handling for proper interaaction with streaming brain - fixes related failed playback issues +Various minor bug fixes causing fanart screen stalls, etc + +[- 0.0.90 -] + +Fix: (Issue #13) Errors caused by encoding issues in episodes pre-play and track playback +Fix: Player settings dialog would sometimes not close without some user input +Fix: (Issue #25) Increased music playback thumbnail resolution - affects add-on player screen and skin views outside the addon +Fix: (Issue #24) Items in movie playlists were not playable +Change: Player settings dialog now puts focus on the selected setting when displaying setting lists +Change: (Issue #21) Now back button only jumps to top bar when top bar is off screen + +[- 0.0.89 -] + +Fix: Selecting non plex pass not managed users caused sign out on addon restart +Fix: Playlists view showed empty space when no audio playlists were present and prevented focus + +[- 0.0.88 -] + +Fix: Starting a video while audio was playing triggered an error causing issues with OSD/control +Fix: Selected audio track was not being played with direct play + +[- 0.0.87 -] + +Initial public beta + +[- 0.0.1 -] + +Initial development \ No newline at end of file diff --git a/script.plexmod/default.py b/script.plexmod/default.py new file mode 100644 index 000000000..3f0bb5be7 --- /dev/null +++ b/script.plexmod/default.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import +from lib import main + + +main.main() diff --git a/script.plexmod/fanart.jpg b/script.plexmod/fanart.jpg new file mode 100644 index 000000000..7548349f6 Binary files /dev/null and b/script.plexmod/fanart.jpg differ diff --git a/script.plexmod/icon.png b/script.plexmod/icon.png new file mode 100644 index 000000000..1a6ba1469 Binary files /dev/null and b/script.plexmod/icon.png differ diff --git a/script.plexmod/lib/__init__.py b/script.plexmod/lib/__init__.py new file mode 100644 index 000000000..92224dc3f --- /dev/null +++ b/script.plexmod/lib/__init__.py @@ -0,0 +1,20 @@ +from __future__ import absolute_import +import requests + +# Disable some warnings. These are not security issue warnings, but alerts to issues that may cause errors +try: + from requests.packages.urllib3.exceptions import InsecurePlatformWarning + requests.packages.urllib3.disable_warnings(InsecurePlatformWarning) +except: + import traceback + traceback.print_exc() + +try: + from requests.packages.urllib3.exceptions import SNIMissingWarning + requests.packages.urllib3.disable_warnings(SNIMissingWarning) +except: + # probably urllib3 >= 2.1.0 + pass + +from . import compat +from . import _included_packages diff --git a/script.plexmod/lib/_included_packages/__init__.py b/script.plexmod/lib/_included_packages/__init__.py new file mode 100644 index 000000000..52d8b592a --- /dev/null +++ b/script.plexmod/lib/_included_packages/__init__.py @@ -0,0 +1,8 @@ +from __future__ import absolute_import +import os +import sys +import inspect + +cmd_folder = os.path.realpath(os.path.abspath(os.path.split(inspect.getfile(inspect.currentframe()))[0])) +if cmd_folder not in sys.path: + sys.path.insert(0, cmd_folder) diff --git a/script.plexmod/lib/_included_packages/icmplib/__init__.py b/script.plexmod/lib/_included_packages/icmplib/__init__.py new file mode 100644 index 000000000..6770e6988 --- /dev/null +++ b/script.plexmod/lib/_included_packages/icmplib/__init__.py @@ -0,0 +1,44 @@ +''' + icmplib + ~~~~~~~ + + Easily forge ICMP packets and make your own ping and traceroute. + + https://github.com/ValentinBELYN/icmplib + + :copyright: Copyright 2017-2023 Valentin BELYN. + :license: GNU LGPLv3, see the LICENSE for details. + + ~~~~~~~ + + This program is free software: you can redistribute it and/or + modify it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this program. If not, see + . +''' + +from .sockets import ICMPv4Socket, ICMPv6Socket, AsyncSocket +from .models import ICMPRequest, ICMPReply, Host, Hop +from .ping import ping, async_ping +from .multiping import multiping, async_multiping +from .traceroute import traceroute +from .exceptions import * +from .utils import is_hostname, is_ipv4_address, is_ipv6_address +from .utils import PID, resolve, async_resolve + + +__author__ = 'Valentin BELYN' +__copyright__ = 'Copyright 2017-2023 Valentin BELYN' +__license__ = 'GNU Lesser General Public License v3.0' + +__version__ = '3.0.4' +__build__ = '231010' diff --git a/script.plexmod/lib/_included_packages/icmplib/exceptions.py b/script.plexmod/lib/_included_packages/icmplib/exceptions.py new file mode 100644 index 000000000..b37646bfa --- /dev/null +++ b/script.plexmod/lib/_included_packages/icmplib/exceptions.py @@ -0,0 +1,214 @@ +''' + icmplib + ~~~~~~~ + + Easily forge ICMP packets and make your own ping and traceroute. + + https://github.com/ValentinBELYN/icmplib + + :copyright: Copyright 2017-2023 Valentin BELYN. + :license: GNU LGPLv3, see the LICENSE for details. + + ~~~~~~~ + + This program is free software: you can redistribute it and/or + modify it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this program. If not, see + . +''' + + +class ICMPLibError(Exception): + ''' + Exception class for the icmplib package. + + ''' + + +class NameLookupError(ICMPLibError): + ''' + Raised when the requested name does not exist or cannot be resolved. + This concerns both Fully Qualified Domain Names and hostnames. + + ''' + def __init__(self, name): + message = f'The name \'{name}\' cannot be resolved' + super().__init__(message) + + +class ICMPSocketError(ICMPLibError): + ''' + Base class for ICMP sockets exceptions. + + ''' + + +class SocketAddressError(ICMPSocketError): + ''' + Raised when the requested address cannot be assigned to the socket. + + ''' + def __init__(self, address): + message = f'The requested address ({address}) cannot be ' \ + 'assigned to the socket' + super().__init__(message) + + +class SocketPermissionError(ICMPSocketError): + ''' + Raised when the privileges are insufficient to create the socket. + + ''' + def __init__(self, privileged): + if privileged: + message = 'Root privileges are required to create the socket' + else: + message = 'A prior configuration of your OS is required ' \ + 'to use ICMP sockets without root privileges. ' \ + 'Read more on https://github.com/ValentinBELYN' \ + '/icmplib' + + super().__init__(message) + + +class SocketUnavailableError(ICMPSocketError): + ''' + Raised when an action is performed while the socket is closed. + + ''' + def __init__(self): + message = 'The socket can no longer be used after its closure' + super().__init__(message) + + +class SocketBroadcastError(ICMPSocketError): + ''' + Raised when a broadcast address is used and the corresponding option + is not enabled on the socket. + + ''' + def __init__(self): + message = 'Broadcast is not allowed: ' \ + 'please use the \'broadcast\' property to allow it' + super().__init__(message) + + +class TimeoutExceeded(ICMPSocketError): + ''' + Raised when a timeout occurs on a socket. + + ''' + def __init__(self, timeout): + message = f'The timeout has been reached ({timeout}s)' + super().__init__(message) + + +class ICMPError(ICMPLibError): + ''' + Base class for ICMP error messages. + + ''' + def __init__(self, message, reply): + super().__init__(message) + self._reply = reply + + @property + def reply(self): + return self._reply + + +class DestinationUnreachable(ICMPError): + ''' + Base class for ICMP Destination Unreachable messages. + + Destination Unreachable message is generated by the host or its + inbound gateway to inform the client that the destination is + unreachable for some reason. + + ''' + _CODES = {} + + def __init__(self, reply): + if reply.code in self._CODES: + message = self._CODES[reply.code] + else: + message = f'Destination unreachable, bad code: {reply.code}' + + super().__init__(message, reply) + + +class ICMPv4DestinationUnreachable(DestinationUnreachable): + _CODES = { + 0: 'Destination network unreachable', + 1: 'Destination host unreachable', + 2: 'Destination protocol unreachable', + 3: 'Destination port unreachable', + 4: 'Fragmentation needed and DF set', + 5: 'Source route failed', + 6: 'Destination network unknown', + 7: 'Destination host unknown', + 8: 'Source host isolated', + 9: 'Destination network prohibed', + 10: 'Destination host prohibed', + 11: 'Destination network unreachable for ToS', + 12: 'Destination host unreachable for ToS', + 13: 'Packet filtered', + 14: 'Precedence violation', + 15: 'Precedence cutoff' + } + + +class ICMPv6DestinationUnreachable(DestinationUnreachable): + _CODES = { + 0: 'No route to destination', + 1: 'Communication with destination administratively prohibited', + 2: 'Beyond scope of source address', + 3: 'Address unreachable', + 4: 'Port unreachable', + 5: 'Source address failed ingress/egress policy', + 6: 'Reject route to destination' + } + + +class TimeExceeded(ICMPError): + ''' + Base class for ICMP Time Exceeded messages. + + Time Exceeded message is generated by a gateway to inform the source + of a discarded datagram due to the time to live field reaching zero. + A Time Exceeded message may also be sent by a host if it fails to + reassemble a fragmented datagram within its time limit. + + ''' + _CODES = {} + + def __init__(self, reply): + if reply.code in self._CODES: + message = self._CODES[reply.code] + else: + message = f'Time exceeded, bad code: {reply.code}' + + super().__init__(message, reply) + + +class ICMPv4TimeExceeded(TimeExceeded): + _CODES = { + 0: 'Time to live exceeded', + 1: 'Fragment reassembly time exceeded' + } + + +class ICMPv6TimeExceeded(TimeExceeded): + _CODES = { + 0: 'Hop limit exceeded', + 1: 'Fragment reassembly time exceeded' + } diff --git a/script.plexmod/lib/_included_packages/icmplib/models.py b/script.plexmod/lib/_included_packages/icmplib/models.py new file mode 100644 index 000000000..c055fdf13 --- /dev/null +++ b/script.plexmod/lib/_included_packages/icmplib/models.py @@ -0,0 +1,491 @@ +''' + icmplib + ~~~~~~~ + + Easily forge ICMP packets and make your own ping and traceroute. + + https://github.com/ValentinBELYN/icmplib + + :copyright: Copyright 2017-2023 Valentin BELYN. + :license: GNU LGPLv3, see the LICENSE for details. + + ~~~~~~~ + + This program is free software: you can redistribute it and/or + modify it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this program. If not, see + . +''' + +from .exceptions import * +from .utils import random_byte_message + + +class ICMPRequest: + ''' + A user-created object that represents an ICMP Echo Request. + + :type destination: str + :param destination: The IP address of the host to which the message + should be sent. + + :type id: int + :param id: The identifier of the request. Used to match the reply + with the request. In practice, a unique identifier is used for + every ping process. On Linux, this identifier is automatically + replaced if the request is sent from an unprivileged socket. + + :type sequence: int + :param sequence: The sequence number. Used to match the reply with + the request. Typically, the sequence number is incremented for + each packet sent during the process. + + :type payload: bytes, optional + :param payload: The payload content in bytes. A random payload is + used by default. + + :type payload_size: int, optional + :param payload_size: The payload size. Ignored when the `payload` + parameter is set. Default to 56. + + :type ttl: int, optional + :param ttl: The time to live of the packet in terms of hops. + Default to 64. + + :type traffic_class: int, optional + :param traffic_class: The traffic class of the ICMP packet. + Provides a defined level of service to the packet by setting the + DS Field (formerly TOS) or the Traffic Class field of the IP + header. Packets are delivered with the minimum priority by + default (Best-effort delivery). + Intermediate routers must be able to support this feature. + Only available on Unix systems. Ignored on Windows. + + ''' + __slots__ = '_destination', '_id', '_sequence', '_payload', \ + '_payload_size', '_ttl', '_traffic_class', '_time' + + def __init__(self, destination, id, sequence, payload=None, + payload_size=56, ttl=64, traffic_class=0): + + if payload: + payload_size = len(payload) + + self._destination = destination + self._id = id & 0xffff + self._sequence = sequence & 0xffff + self._payload = payload + self._payload_size = payload_size + self._ttl = ttl + self._traffic_class = traffic_class + self._time = 0 + + def __repr__(self): + return f'' + + @property + def destination(self): + ''' + The IP address of the host to which the message should be sent. + + ''' + return self._destination + + @property + def id(self): + ''' + The identifier of the request. + Used to match the reply with the request. + + ''' + return self._id + + @property + def sequence(self): + ''' + The sequence number. + Used to match the reply with the request. + + ''' + return self._sequence + + @property + def payload(self): + ''' + The payload content in bytes. + Return a random payload if not defined. + + ''' + return self._payload or random_byte_message(self._payload_size) + + @property + def payload_size(self): + ''' + The payload size. + + ''' + return self._payload_size + + @property + def ttl(self): + ''' + The time to live of the packet in terms of hops. + + ''' + return self._ttl + + @property + def traffic_class(self): + ''' + The traffic class of the packet. + + ''' + return self._traffic_class + + @property + def time(self): + ''' + The timestamp of the ICMP request. + + Initialized to zero when creating the request and replaced by + the `send` method of an ICMP socket with the time of sending. + + ''' + return self._time + + +class ICMPReply: + ''' + A class that represents an ICMP reply. Generated from an ICMP socket. + + :type source: str + :param source: The IP address of the host that composes the ICMP + message. + + :type family: int + :param family: The address family. Can be set to `4` for IPv4 or `6` + for IPv6 addresses. + + :type id: int + :param id: The identifier of the reply. Used to match the reply with + the request. + + :type sequence: int + :param sequence: The sequence number. Used to match the reply with + the request. + + :type type: int + :param type: The type of ICMP message. + + :type code: int + :param code: The ICMP error code. + + :type bytes_received: int + :param bytes_received: The number of bytes received. + + :type time: float + :param time: The timestamp of the ICMP reply. + + ''' + __slots__ = '_source', '_family', '_id', '_sequence', '_type', \ + '_code', '_bytes_received', '_time' + + def __init__(self, source, family, id, sequence, type, code, + bytes_received, time): + + self._source = source + self._family = family + self._id = id + self._sequence = sequence + self._type = type + self._code = code + self._bytes_received = bytes_received + self._time = time + + def __repr__(self): + return f'' + + def raise_for_status(self): + ''' + Throw an exception if the reply is not an ICMP Echo Reply. + Otherwise, do nothing. + + :raises DestinationUnreachable: If the destination is + unreachable for some reason. + :raises TimeExceeded: If the time to live field of the ICMP + request has reached zero. + :raises ICMPError: Raised for any other type and ICMP error + code, except ICMP Echo Reply messages. + + ''' + if self._family == 6: + if self._type == 1: + raise ICMPv6DestinationUnreachable(self) + + if self._type == 3: + raise ICMPv6TimeExceeded(self) + else: + if self._type == 3: + raise ICMPv4DestinationUnreachable(self) + + if self._type == 11: + raise ICMPv4TimeExceeded(self) + + if (self._family == 4 and self._type != 0 or + self._family == 6 and self._type != 129): + message = f'Error type: {self._type}, code: {self._code}' + raise ICMPError(message, self) + + @property + def source(self): + ''' + The IP address of the host that composes the ICMP message. + + ''' + return self._source + + @property + def id(self): + ''' + The identifier of the reply. + Used to match the reply with the request. + + ''' + return self._id + + @property + def sequence(self): + ''' + The sequence number. + Used to match the reply with the request. + + ''' + return self._sequence + + @property + def type(self): + ''' + The type of ICMP message. + + ''' + return self._type + + @property + def code(self): + ''' + The ICMP error code. + + ''' + return self._code + + @property + def bytes_received(self): + ''' + The number of bytes received. + + ''' + return self._bytes_received + + @property + def time(self): + ''' + The timestamp of the ICMP reply. + + ''' + return self._time + + +class Host: + ''' + A class that represents a host. It simplifies the use of the results + from the `ping`, `multiping` and `traceroute` functions. + + :type address: str + :param address: The IP address of the host that responded to the + request. + + :type packets_sent: int + :param packets_sent: The number of packets transmitted to the + destination host. + + :type rtts: list[float] + :param rtts: The list of round-trip times expressed in milliseconds. + + ''' + __slots__ = '_address', '_packets_sent', '_rtts' + + def __init__(self, address, packets_sent, rtts): + self._address = address + self._packets_sent = packets_sent + self._rtts = rtts + + def __repr__(self): + return f'' + + def __str__(self): + return f' {self._address}\n' + '-' * 60 + '\n' \ + f' Packets sent: {self._packets_sent}\n' \ + f' Packets received: {self.packets_received}\n' \ + f' Packet loss: {self.packet_loss * 100}%\n' \ + f' Round-trip times: {self.min_rtt} ms / ' \ + f'{self.avg_rtt} ms / {self.max_rtt} ms\n' \ + f' Jitter: {self.jitter} ms\n' + '-' * 60 + + @property + def address(self): + ''' + The IP address of the host that responded to the request. + + ''' + return self._address + + @property + def min_rtt(self): + ''' + The minimum round-trip time in milliseconds. + + ''' + if not self._rtts: + return 0.0 + + return round(min(self._rtts), 3) + + @property + def avg_rtt(self): + ''' + The average round-trip time in milliseconds. + + ''' + if not self._rtts: + return 0.0 + + return round(sum(self._rtts) / len(self._rtts), 3) + + @property + def max_rtt(self): + ''' + The maximum round-trip time in milliseconds. + + ''' + if not self._rtts: + return 0.0 + + return round(max(self._rtts), 3) + + @property + def rtts(self): + ''' + The list of round-trip times expressed in milliseconds. + + ''' + return self._rtts + + @property + def packets_sent(self): + ''' + The number of requests transmitted to the remote host. + + ''' + return self._packets_sent + + @property + def packets_received(self): + ''' + The number of ICMP responses received from the remote host. + + ''' + return len(self._rtts) + + @property + def packet_loss(self): + ''' + Packet loss occurs when packets fail to reach their destination. + Return a `float` between 0 and 1 (all packets are lost). + + ''' + if not self._packets_sent: + return 0.0 + + return round(1 - len(self._rtts) / self._packets_sent, 2) + + @property + def jitter(self): + ''' + The jitter in milliseconds, defined as the variance of the + latency of packets flowing through the network. + + At least two ICMP responses are required to calculate the + jitter. + + ''' + sum_deltas = 0.0 + num_deltas = len(self._rtts) - 1 + + if num_deltas < 1: + return 0.0 + + for i in range(num_deltas): + sum_deltas += abs(self._rtts[i] - self._rtts[i + 1]) + + return round(sum_deltas / num_deltas, 3) + + @property + def is_alive(self): + ''' + Indicate whether the host is reachable. + Return a `boolean`. + + ''' + return len(self._rtts) > 0 + + +class Hop(Host): + ''' + A class that represents a hop. It extends the `Host` class and adds + some features for the `traceroute` function. + + :type address: str + :param address: The IP address of the gateway or host that responded + to the request. + + :type packets_sent: int + :param packets_sent: The number of packets transmitted to the + destination host. + + :type rtts: list[float] + :param rtts: The list of round-trip times expressed in milliseconds. + + :type distance: int + :param distance: The distance, in terms of hops, that separates the + remote host from the current machine. + + ''' + __slots__ = '_address', '_packets_sent', '_rtts', '_distance' + + def __init__(self, address, packets_sent, rtts, distance): + super().__init__(address, packets_sent, rtts) + self._distance = distance + + def __repr__(self): + return f'' + + def __str__(self): + return f' #{self._distance:<2} {super().__str__()[2:]}' + + @property + def distance(self): + ''' + The distance, in terms of hops, that separates the remote host + from the current machine. + + ''' + return self._distance diff --git a/script.plexmod/lib/_included_packages/icmplib/multiping.py b/script.plexmod/lib/_included_packages/icmplib/multiping.py new file mode 100644 index 000000000..0705b281d --- /dev/null +++ b/script.plexmod/lib/_included_packages/icmplib/multiping.py @@ -0,0 +1,277 @@ +''' + icmplib + ~~~~~~~ + + Easily forge ICMP packets and make your own ping and traceroute. + + https://github.com/ValentinBELYN/icmplib + + :copyright: Copyright 2017-2023 Valentin BELYN. + :license: GNU LGPLv3, see the LICENSE for details. + + ~~~~~~~ + + This program is free software: you can redistribute it and/or + modify it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this program. If not, see + . +''' + +import asyncio + +from .ping import async_ping + + +async def async_multiping(addresses, count=2, interval=0.5, timeout=2, + concurrent_tasks=50, source=None, family=None, privileged=True, + **kwargs): + ''' + Send ICMP Echo Request packets to several network hosts. + + This function is non-blocking. + + :type addresses: list[str] + :param addresses: The IP addresses of the hosts to which messages + should be sent. Hostnames and FQDNs are allowed but not + recommended. You can easily retrieve their IP address by calling + the built-in `async_resolve` function. + + :type count: int, optional + :param count: The number of ping to perform per address. + Default to 2. + + :type interval: int or float, optional + :param interval: The interval in seconds between sending each packet. + Default to 0.5. + + :type timeout: int or float, optional + :param timeout: The maximum waiting time for receiving a reply in + seconds. Default to 2. + + :type concurrent_tasks: int, optional + :param concurrent_tasks: The maximum number of concurrent tasks to + speed up processing. This value cannot exceed the maximum number + of file descriptors configured on the operating system. + Default to 50. + + :type source: str, optional + :param source: The IP address from which you want to send packets. + By default, the interface is automatically chosen according to + the specified destinations. This parameter should not be used if + you are passing both IPv4 and IPv6 addresses to this function. + + :type family: int, optional + :param family: The address family if a hostname or FQDN is specified. + Can be set to `4` for IPv4 or `6` for IPv6 addresses. By default, + this function searches for IPv4 addresses first before searching + for IPv6 addresses. + + :type privileged: bool, optional + :param privileged: When this option is enabled, this library fully + manages the exchanges and the structure of ICMP packets. + Disable this option if you want to use this function without + root privileges and let the kernel handle ICMP headers. + Default to True. + Only available on Unix systems. Ignored on Windows. + + Advanced (**kwags): + + :type payload: bytes, optional + :param payload: The payload content in bytes. A random payload is + used by default. + + :type payload_size: int, optional + :param payload_size: The payload size. Ignored when the `payload` + parameter is set. Default to 56. + + :type traffic_class: int, optional + :param traffic_class: The traffic class of ICMP packets. + Provides a defined level of service to packets by setting the DS + Field (formerly TOS) or the Traffic Class field of IP headers. + Packets are delivered with the minimum priority by default + (Best-effort delivery). + Intermediate routers must be able to support this feature. + Only available on Unix systems. Ignored on Windows. + + :rtype: list[Host] + :returns: A list of `Host` objects containing statistics about the + desired destinations. The list is sorted in the same order as + the addresses passed in parameters. + + :raises NameLookupError: If you pass a hostname or FQDN in + parameters and it does not exist or cannot be resolved. + :raises SocketPermissionError: If the privileges are insufficient to + create the socket. + :raises SocketAddressError: If the source address cannot be assigned + to the socket. + :raises ICMPSocketError: If another error occurs. See the + `ICMPv4Socket` or `ICMPv6Socket` class for details. + + Usage:: + + >>> import asyncio + >>> from icmplib import async_multiping + >>> hosts = asyncio.run(async_multiping(['10.0.0.5', '::1'])) + + >>> for host in hosts: + ... if host.is_alive: + ... print(f'{host.address} is up!') + ... else: + ... print(f'{host.address} is down!') + + 10.0.0.5 is down! + ::1 is up! + + See the `Host` class for details. + + ''' + loop = asyncio.get_running_loop() + tasks = [] + tasks_pending = set() + + for address in addresses: + if len(tasks_pending) >= concurrent_tasks: + _, tasks_pending = await asyncio.wait( + tasks_pending, + return_when=asyncio.FIRST_COMPLETED) + + task = loop.create_task( + async_ping( + address=address, + count=count, + interval=interval, + timeout=timeout, + source=source, + family=family, + privileged=privileged, + **kwargs)) + + tasks.append(task) + tasks_pending.add(task) + + await asyncio.wait(tasks_pending) + + return [task.result() for task in tasks] + + +def multiping(addresses, count=2, interval=0.5, timeout=2, + concurrent_tasks=50, source=None, family=None, privileged=True, + **kwargs): + ''' + Send ICMP Echo Request packets to several network hosts. + + :type addresses: list[str] + :param addresses: The IP addresses of the hosts to which messages + should be sent. Hostnames and FQDNs are allowed but not + recommended. You can easily retrieve their IP address by calling + the built-in `resolve` function. + + :type count: int, optional + :param count: The number of ping to perform per address. + Default to 2. + + :type interval: int or float, optional + :param interval: The interval in seconds between sending each packet. + Default to 0.5. + + :type timeout: int or float, optional + :param timeout: The maximum waiting time for receiving a reply in + seconds. Default to 2. + + :type concurrent_tasks: int, optional + :param concurrent_tasks: The maximum number of concurrent tasks to + speed up processing. This value cannot exceed the maximum number + of file descriptors configured on the operating system. + Default to 50. + + :type source: str, optional + :param source: The IP address from which you want to send packets. + By default, the interface is automatically chosen according to + the specified destinations. This parameter should not be used if + you are passing both IPv4 and IPv6 addresses to this function. + + :type family: int, optional + :param family: The address family if a hostname or FQDN is specified. + Can be set to `4` for IPv4 or `6` for IPv6 addresses. By default, + this function searches for IPv4 addresses first before searching + for IPv6 addresses. + + :type privileged: bool, optional + :param privileged: When this option is enabled, this library fully + manages the exchanges and the structure of ICMP packets. + Disable this option if you want to use this function without + root privileges and let the kernel handle ICMP headers. + Default to True. + Only available on Unix systems. Ignored on Windows. + + Advanced (**kwags): + + :type payload: bytes, optional + :param payload: The payload content in bytes. A random payload is + used by default. + + :type payload_size: int, optional + :param payload_size: The payload size. Ignored when the `payload` + parameter is set. Default to 56. + + :type traffic_class: int, optional + :param traffic_class: The traffic class of ICMP packets. + Provides a defined level of service to packets by setting the DS + Field (formerly TOS) or the Traffic Class field of IP headers. + Packets are delivered with the minimum priority by default + (Best-effort delivery). + Intermediate routers must be able to support this feature. + Only available on Unix systems. Ignored on Windows. + + :rtype: list[Host] + :returns: A list of `Host` objects containing statistics about the + desired destinations. The list is sorted in the same order as + the addresses passed in parameters. + + :raises NameLookupError: If you pass a hostname or FQDN in + parameters and it does not exist or cannot be resolved. + :raises SocketPermissionError: If the privileges are insufficient to + create the socket. + :raises SocketAddressError: If the source address cannot be assigned + to the socket. + :raises ICMPSocketError: If another error occurs. See the + `ICMPv4Socket` or `ICMPv6Socket` class for details. + + Usage:: + + >>> from icmplib import multiping + >>> hosts = multiping(['10.0.0.5', '127.0.0.1', '::1']) + + >>> for host in hosts: + ... if host.is_alive: + ... print(f'{host.address} is up!') + ... else: + ... print(f'{host.address} is down!') + + 10.0.0.5 is down! + 127.0.0.1 is up! + ::1 is up! + + See the `Host` class for details. + + ''' + return asyncio.run( + async_multiping( + addresses=addresses, + count=count, + interval=interval, + timeout=timeout, + concurrent_tasks=concurrent_tasks, + source=source, + family=family, + privileged=privileged, + **kwargs)) diff --git a/script.plexmod/lib/_included_packages/icmplib/ping.py b/script.plexmod/lib/_included_packages/icmplib/ping.py new file mode 100644 index 000000000..5ba1efc8b --- /dev/null +++ b/script.plexmod/lib/_included_packages/icmplib/ping.py @@ -0,0 +1,298 @@ +''' + icmplib + ~~~~~~~ + + Easily forge ICMP packets and make your own ping and traceroute. + + https://github.com/ValentinBELYN/icmplib + + :copyright: Copyright 2017-2023 Valentin BELYN. + :license: GNU LGPLv3, see the LICENSE for details. + + ~~~~~~~ + + This program is free software: you can redistribute it and/or + modify it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this program. If not, see + . +''' + +import asyncio +from time import sleep + +from .sockets import ICMPv4Socket, ICMPv6Socket, AsyncSocket +from .models import ICMPRequest, Host +from .exceptions import ICMPLibError +from .utils import * + + +def ping(address, count=4, interval=1, timeout=2, id=None, source=None, + family=None, privileged=True, **kwargs): + ''' + Send ICMP Echo Request packets to a network host. + + :type address: str + :param address: The IP address, hostname or FQDN of the host to + which messages should be sent. For deterministic behavior, + prefer to use an IP address. + + :type count: int, optional + :param count: The number of ping to perform. Default to 4. + + :type interval: int or float, optional + :param interval: The interval in seconds between sending each packet. + Default to 1. + + :type timeout: int or float, optional + :param timeout: The maximum waiting time for receiving a reply in + seconds. Default to 2. + + :type id: int, optional + :param id: The identifier of ICMP requests. Used to match the + responses with requests. In practice, a unique identifier should + be used for every ping process. On Linux, this identifier is + ignored when the `privileged` parameter is disabled. The library + handles this identifier itself by default. + + :type source: str, optional + :param source: The IP address from which you want to send packets. + By default, the interface is automatically chosen according to + the specified destination. + + :type family: int, optional + :param family: The address family if a hostname or FQDN is specified. + Can be set to `4` for IPv4 or `6` for IPv6 addresses. By default, + this function searches for IPv4 addresses first before searching + for IPv6 addresses. + + :type privileged: bool, optional + :param privileged: When this option is enabled, this library fully + manages the exchanges and the structure of ICMP packets. + Disable this option if you want to use this function without + root privileges and let the kernel handle ICMP headers. + Default to True. + Only available on Unix systems. Ignored on Windows. + + Advanced (**kwags): + + :type payload: bytes, optional + :param payload: The payload content in bytes. A random payload is + used by default. + + :type payload_size: int, optional + :param payload_size: The payload size. Ignored when the `payload` + parameter is set. Default to 56. + + :type traffic_class: int, optional + :param traffic_class: The traffic class of ICMP packets. + Provides a defined level of service to packets by setting the DS + Field (formerly TOS) or the Traffic Class field of IP headers. + Packets are delivered with the minimum priority by default + (Best-effort delivery). + Intermediate routers must be able to support this feature. + Only available on Unix systems. Ignored on Windows. + + :rtype: Host + :returns: A `Host` object containing statistics about the desired + destination. + + :raises NameLookupError: If you pass a hostname or FQDN in + parameters and it does not exist or cannot be resolved. + :raises SocketPermissionError: If the privileges are insufficient to + create the socket. + :raises SocketAddressError: If the source address cannot be assigned + to the socket. + :raises ICMPSocketError: If another error occurs. See the + `ICMPv4Socket` or `ICMPv6Socket` class for details. + + Usage:: + + >>> from icmplib import ping + >>> host = ping('1.1.1.1') + >>> host.avg_rtt + 13.2 + >>> host.is_alive + True + + See the `Host` class for details. + + ''' + if is_hostname(address): + address = resolve(address, family)[0] + + if is_ipv6_address(address): + _Socket = ICMPv6Socket + else: + _Socket = ICMPv4Socket + + id = id or unique_identifier() + packets_sent = 0 + rtts = [] + + with _Socket(source, privileged) as sock: + for sequence in range(count): + if sequence > 0: + sleep(interval) + + request = ICMPRequest( + destination=address, + id=id, + sequence=sequence, + **kwargs) + + try: + sock.send(request) + packets_sent += 1 + + reply = sock.receive(request, timeout) + reply.raise_for_status() + + rtt = (reply.time - request.time) * 1000 + rtts.append(rtt) + + except ICMPLibError: + pass + + return Host(address, packets_sent, rtts) + + +async def async_ping(address, count=4, interval=1, timeout=2, id=None, + source=None, family=None, privileged=True, **kwargs): + ''' + Send ICMP Echo Request packets to a network host. + + This function is non-blocking. + + :type address: str + :param address: The IP address, hostname or FQDN of the host to + which messages should be sent. For deterministic behavior, + prefer to use an IP address. + + :type count: int, optional + :param count: The number of ping to perform. Default to 4. + + :type interval: int or float, optional + :param interval: The interval in seconds between sending each packet. + Default to 1. + + :type timeout: int or float, optional + :param timeout: The maximum waiting time for receiving a reply in + seconds. Default to 2. + + :type id: int, optional + :param id: The identifier of ICMP requests. Used to match the + responses with requests. In practice, a unique identifier should + be used for every ping process. On Linux, this identifier is + ignored when the `privileged` parameter is disabled. The library + handles this identifier itself by default. + + :type source: str, optional + :param source: The IP address from which you want to send packets. + By default, the interface is automatically chosen according to + the specified destination. + + :type family: int, optional + :param family: The address family if a hostname or FQDN is specified. + Can be set to `4` for IPv4 or `6` for IPv6 addresses. By default, + this function searches for IPv4 addresses first before searching + for IPv6 addresses. + + :type privileged: bool, optional + :param privileged: When this option is enabled, this library fully + manages the exchanges and the structure of ICMP packets. + Disable this option if you want to use this function without + root privileges and let the kernel handle ICMP headers. + Default to True. + Only available on Unix systems. Ignored on Windows. + + Advanced (**kwags): + + :type payload: bytes, optional + :param payload: The payload content in bytes. A random payload is + used by default. + + :type payload_size: int, optional + :param payload_size: The payload size. Ignored when the `payload` + parameter is set. Default to 56. + + :type traffic_class: int, optional + :param traffic_class: The traffic class of ICMP packets. + Provides a defined level of service to packets by setting the DS + Field (formerly TOS) or the Traffic Class field of IP headers. + Packets are delivered with the minimum priority by default + (Best-effort delivery). + Intermediate routers must be able to support this feature. + Only available on Unix systems. Ignored on Windows. + + :rtype: Host + :returns: A `Host` object containing statistics about the desired + destination. + + :raises NameLookupError: If you pass a hostname or FQDN in + parameters and it does not exist or cannot be resolved. + :raises SocketPermissionError: If the privileges are insufficient to + create the socket. + :raises SocketAddressError: If the source address cannot be assigned + to the socket. + :raises ICMPSocketError: If another error occurs. See the + `ICMPv4Socket` or `ICMPv6Socket` class for details. + + Usage:: + + >>> import asyncio + >>> from icmplib import async_ping + >>> host = asyncio.run(async_ping('1.1.1.1')) + >>> host.avg_rtt + 13.2 + >>> host.is_alive + True + + See the `Host` class for details. + + ''' + if is_hostname(address): + address = (await async_resolve(address, family))[0] + + if is_ipv6_address(address): + _Socket = ICMPv6Socket + else: + _Socket = ICMPv4Socket + + id = id or unique_identifier() + packets_sent = 0 + rtts = [] + + with AsyncSocket(_Socket(source, privileged)) as sock: + for sequence in range(count): + if sequence > 0: + await asyncio.sleep(interval) + + request = ICMPRequest( + destination=address, + id=id, + sequence=sequence, + **kwargs) + + try: + sock.send(request) + packets_sent += 1 + + reply = await sock.receive(request, timeout) + reply.raise_for_status() + + rtt = (reply.time - request.time) * 1000 + rtts.append(rtt) + + except ICMPLibError: + pass + + return Host(address, packets_sent, rtts) diff --git a/script.plexmod/lib/_included_packages/icmplib/sockets.py b/script.plexmod/lib/_included_packages/icmplib/sockets.py new file mode 100644 index 000000000..130f55a3a --- /dev/null +++ b/script.plexmod/lib/_included_packages/icmplib/sockets.py @@ -0,0 +1,806 @@ +''' + icmplib + ~~~~~~~ + + Easily forge ICMP packets and make your own ping and traceroute. + + https://github.com/ValentinBELYN/icmplib + + :copyright: Copyright 2017-2023 Valentin BELYN. + :license: GNU LGPLv3, see the LICENSE for details. + + ~~~~~~~ + + This program is free software: you can redistribute it and/or + modify it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this program. If not, see + . +''' + +import socket, asyncio +from struct import pack, unpack +from time import time + +from .models import ICMPReply +from .exceptions import * +from .utils import PLATFORM_LINUX, PLATFORM_MACOS, PLATFORM_WINDOWS + + +class ICMPSocket: + ''' + Base class for ICMP sockets. + + :type address: str, optional + :param address: The IP address from which you want to listen and + send packets. By default, the socket listens on all interfaces. + + :type privileged: bool, optional + :param privileged: When this option is enabled, the socket fully + manages the exchanges and the structure of the ICMP packets. + Disable this option if you want to instantiate and use the + socket without root privileges and let the kernel handle ICMP + headers. Default to True. + Only available on Unix systems. Ignored on Windows. + + :raises SocketPermissionError: If the privileges are insufficient to + create the socket. + :raises SocketAddressError: If the requested address cannot be + assigned to the socket. + :raises ICMPSocketError: If another error occurs while creating the + socket. + + ''' + __slots__ = '_sock', '_address', '_privileged' + + _IP_VERSION = -1 + _ICMP_HEADER_OFFSET = -1 + _ICMP_HEADER_REAL_OFFSET = -1 + + _ICMP_CODE_OFFSET = _ICMP_HEADER_OFFSET + 1 + _ICMP_CHECKSUM_OFFSET = _ICMP_HEADER_OFFSET + 2 + _ICMP_ID_OFFSET = _ICMP_HEADER_OFFSET + 4 + _ICMP_SEQUENCE_OFFSET = _ICMP_HEADER_OFFSET + 6 + _ICMP_PAYLOAD_OFFSET = _ICMP_HEADER_OFFSET + 8 + + _ICMP_ECHO_REQUEST = -1 + _ICMP_ECHO_REPLY = -1 + + def __init__(self, address=None, privileged=True): + self._sock = None + self._address = address + + # The Linux kernel allows unprivileged users to use datagram + # sockets (SOCK_DGRAM) to send ICMP requests. This feature is + # now supported by the majority of Unix systems. + # Windows is not compatible. + self._privileged = privileged or PLATFORM_WINDOWS + + try: + self._sock = self._create_socket( + socket.SOCK_RAW if self._privileged else + socket.SOCK_DGRAM) + + if address: + self._sock.bind((address, 0)) + + except OSError as err: + if err.errno in (1, 13, 10013): + raise SocketPermissionError(privileged) + + if err.errno in (-9, 49, 99, 10049, 11001): + raise SocketAddressError(address) + + raise ICMPSocketError(str(err)) + + def __enter__(self): + ''' + Return this object. + + ''' + return self + + def __exit__(self, type, value, traceback): + ''' + Call the `close` method. + + ''' + self.close() + + def __del__(self): + ''' + Call the `close` method. + + ''' + self.close() + + def _create_socket(self, type): + ''' + Create and return a new socket. Must be overridden. + + ''' + raise NotImplementedError + + def _set_ttl(self, ttl): + ''' + Set the time to live of every IP packet originating from this + socket. Must be overridden. + + ''' + raise NotImplementedError + + def _set_traffic_class(self, traffic_class): + ''' + Set the DS Field (formerly TOS) or the Traffic Class field of + every IP packet originating from this socket. Must be + overridden. + + ''' + raise NotImplementedError + + def _checksum(self, data): + ''' + Compute the checksum of an ICMP packet. Checksums are used to + verify the integrity of packets. + + ''' + sum = 0 + data += b'\x00' + + for i in range(0, len(data) - 1, 2): + sum += (data[i] << 8) + data[i + 1] + sum = (sum & 0xffff) + (sum >> 16) + + sum = ~sum & 0xffff + + return sum + + def _create_packet(self, id, sequence, payload): + ''' + Build an ICMP packet from an identifier, a sequence number and + a payload. + + This method returns the newly created ICMP header concatenated + to the payload passed in parameters. + + ''' + checksum = 0 + + # Temporary ICMP header to compute the checksum + header = pack('!2B3H', self._ICMP_ECHO_REQUEST, 0, checksum, + id, sequence) + + checksum = self._checksum(header + payload) + + # Definitive ICMP header + header = pack('!2B3H', self._ICMP_ECHO_REQUEST, 0, checksum, + id, sequence) + + return header + payload + + def _parse_reply(self, packet, source, current_time): + ''' + Parse an ICMP reply from bytes. + + This method returns an `ICMPReply` object or `None` if the reply + cannot be parsed. + + ''' + # On Linux, the IP header is missing when a datagram socket is + # used (SOCK_DGRAM). To keep the same behavior on all operating + # systems including macOS which has this header, we add a + # padding of the size of the missing IP header. + if not self._privileged and PLATFORM_LINUX: + packet = b'\x00' * self._ICMP_HEADER_OFFSET + packet + + bytes_received = len(packet) - self._ICMP_HEADER_OFFSET + + if len(packet) < self._ICMP_CHECKSUM_OFFSET: + return None + + type, code = unpack('!2B', packet[ + self._ICMP_HEADER_OFFSET: + self._ICMP_CHECKSUM_OFFSET]) + + if type != self._ICMP_ECHO_REPLY: + packet = packet[ + self._ICMP_PAYLOAD_OFFSET + - self._ICMP_HEADER_OFFSET + + self._ICMP_HEADER_REAL_OFFSET:] + + if len(packet) < self._ICMP_PAYLOAD_OFFSET: + return None + + id, sequence = unpack('!2H', packet[ + self._ICMP_ID_OFFSET: + self._ICMP_PAYLOAD_OFFSET]) + + return ICMPReply( + source=source, + family=self._IP_VERSION, + id=id, + sequence=sequence, + type=type, + code=code, + bytes_received=bytes_received, + time=current_time) + + def send(self, request): + ''' + Send an ICMP request message over the network to a remote host. + + This operation is non-blocking. Use the `receive` method to get + the reply. + + :type request: ICMPRequest + :param request: The ICMP request you have created. If the socket + is used in non-privileged mode on a Linux system, the + identifier defined in the request will be replaced by the + kernel. + + :raises SocketBroadcastError: If a broadcast address is used and + the corresponding option is not enabled on the socket + (ICMPv4 only). + :raises SocketUnavailableError: If the socket is closed. + :raises ICMPSocketError: If another error occurs while sending. + + ''' + if not self._sock: + raise SocketUnavailableError + + try: + sock_destination = socket.getaddrinfo( + host=request.destination, + port=None, + family=self._sock.family, + type=self._sock.type)[0][4] + + packet = self._create_packet( + id=request.id, + sequence=request.sequence, + payload=request.payload) + + self._set_ttl(request.ttl) + self._set_traffic_class(request.traffic_class) + + request._time = time() + self._sock.sendto(packet, sock_destination) + + # On Linux, the ICMP request identifier is replaced by the + # kernel with a random port number when a datagram socket is + # used (SOCK_DGRAM). So, we update the request created by + # the user to take this new identifier into account. + if not self._privileged and PLATFORM_LINUX: + request._id = self._sock.getsockname()[1] + + except PermissionError: + raise SocketBroadcastError + + except OSError as err: + raise ICMPSocketError(str(err)) + + def receive(self, request=None, timeout=2): + ''' + Receive an ICMP reply message from the socket. + + This method can be called multiple times if you expect several + responses as with a broadcast address. + + :type request: ICMPRequest, optional + :param request: The ICMP request to use to match the response. + By default, all ICMP packets arriving on the socket are + returned. + + :type timeout: int or float, optional + :param timeout: The maximum waiting time for receiving the + response in seconds. Default to 2. + + :rtype: ICMPReply + :returns: An `ICMPReply` object representing the response of the + desired destination or an upstream gateway. See the + `ICMPReply` class for details. + + :raises TimeoutExceeded: If no response is received before the + timeout specified in parameters. + :raises SocketUnavailableError: If the socket is closed. + :raises ICMPSocketError: If another error occurs while receiving. + + ''' + if not self._sock: + raise SocketUnavailableError + + self._sock.settimeout(timeout) + time_limit = time() + timeout + + try: + while True: + response = self._sock.recvfrom(1024) + current_time = time() + + packet = response[0] + source = response[1][0] + + if current_time > time_limit: + raise socket.timeout + + reply = self._parse_reply( + packet=packet, + source=source, + current_time=current_time) + + if (reply and not request or + reply and request.id == reply.id and + request.sequence == reply.sequence): + return reply + + except socket.timeout: + raise TimeoutExceeded(timeout) + + except OSError as err: + raise ICMPSocketError(str(err)) + + def close(self): + ''' + Close the socket. It cannot be used after this call. + + ''' + if self._sock: + self._sock.close() + self._sock = None + + @property + def sock(self): + ''' + Return the underlying socket (`socket.socket` object) or `None` + if the socket is closed. + + This property should only be used if the feature you want is not + yet implemented. Some changes made to this socket may cause + unexpected behavior or be incompatible with later versions of + the library. + + Prefer to use the other methods and properties defined within + this class if possible. + + ''' + return self._sock + + @property + def blocking(self): + ''' + Indicate whether the socket is in blocking mode. + Return a `boolean`. + + ''' + return self._sock.getblocking() + + @blocking.setter + def blocking(self, enable): + return self._sock.setblocking(enable) + + @property + def address(self): + ''' + The IP address from which the socket listens and sends packets. + Return `None` if the socket listens on all interfaces. + + ''' + return self._address + + @property + def is_privileged(self): + ''' + Indicate whether the socket is running in privileged mode. + Return a `boolean`. + + ''' + return self._privileged + + @property + def is_closed(self): + ''' + Indicate whether the socket is closed. + Return a `boolean`. + + ''' + return self._sock is None + + +# Echo Request and Echo Reply messages RFC 792 +# +# 0 1 2 3 +# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Type | Code | Checksum | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Identifier | Sequence Number | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Data ... +# +-+-+-+-+- +# +# ICMPv4 Error message RFC 792 +# +# 0 1 2 3 +# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Type | Code | Checksum | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Unused / Depends on the error | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Internet Header + 64 bits of Original Data Datagram | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + +class ICMPv4Socket(ICMPSocket): + ''' + Class for sending and receiving ICMPv4 packets. + + :type address: str, optional + :param address: The IP address from which you want to listen and + send packets. By default, the socket listens on all interfaces. + + :type privileged: bool, optional + :param privileged: When this option is enabled, the socket fully + manages the exchanges and the structure of the ICMP packets. + Disable this option if you want to instantiate and use the + socket without root privileges and let the kernel handle ICMP + headers. Default to True. + Only available on Unix systems. Ignored on Windows. + + :raises SocketPermissionError: If the privileges are insufficient to + create the socket. + :raises SocketAddressError: If the requested address cannot be + assigned to the socket. + :raises ICMPSocketError: If another error occurs while creating the + socket. + + ''' + __slots__ = '_sock', '_address', '_privileged' + + _IP_VERSION = 4 + _ICMP_HEADER_OFFSET = 20 + _ICMP_HEADER_REAL_OFFSET = 20 + + _ICMP_CODE_OFFSET = _ICMP_HEADER_OFFSET + 1 + _ICMP_CHECKSUM_OFFSET = _ICMP_HEADER_OFFSET + 2 + _ICMP_ID_OFFSET = _ICMP_HEADER_OFFSET + 4 + _ICMP_SEQUENCE_OFFSET = _ICMP_HEADER_OFFSET + 6 + _ICMP_PAYLOAD_OFFSET = _ICMP_HEADER_OFFSET + 8 + + _ICMP_ECHO_REQUEST = 8 + _ICMP_ECHO_REPLY = 0 + + def _create_socket(self, type): + ''' + Create and return a new socket. + + ''' + return socket.socket( + family=socket.AF_INET, + type=type, + proto=socket.IPPROTO_ICMP) + + def _set_ttl(self, ttl): + ''' + Set the time to live of every IP packet originating from this + socket. + + ''' + self._sock.setsockopt( + socket.IPPROTO_IP, + socket.IP_TTL, + ttl) + + def _set_traffic_class(self, traffic_class): + ''' + Set the DS Field (formerly TOS) of every IP packet originating + from this socket. + + Only available on Unix systems. Ignored on Windows. + + ''' + # Not available on Windows + if PLATFORM_WINDOWS: + return + + self._sock.setsockopt( + socket.IPPROTO_IP, + socket.IP_TOS, + traffic_class) + + @property + def broadcast(self): + ''' + Indicate whether broadcast support is enabled on the socket. + Return a `boolean`. + + .. note:: + To enable broadcast support: + icmp_socket.broadcast = True + + ''' + return self._sock.getsockopt( + socket.SOL_SOCKET, + socket.SO_BROADCAST) > 0 + + @broadcast.setter + def broadcast(self, enable): + self._sock.setsockopt( + socket.SOL_SOCKET, + socket.SO_BROADCAST, + enable) + + +# Echo Request and Echo Reply messages RFC 4443 +# +# 0 1 2 3 +# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Type | Code | Checksum | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Identifier | Sequence Number | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Data ... +# +-+-+-+-+- +# +# ICMPv6 Error message RFC 4443 +# +# 0 1 2 3 +# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Type | Code | Checksum | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Unused / Depends on the error | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +# | Original packet without exceed the minimum IPv6 MTU | +# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +# Windows IPv6 compatibility +if PLATFORM_WINDOWS: + socket.IPPROTO_IPV6 = 41 + socket.IPPROTO_ICMPV6 = 58 + + +class ICMPv6Socket(ICMPSocket): + ''' + Class for sending and receiving ICMPv6 packets. + + :type address: str, optional + :param address: The IP address from which you want to listen and + send packets. By default, the socket listens on all interfaces. + + :type privileged: bool, optional + :param privileged: When this option is enabled, the socket fully + manages the exchanges and the structure of the ICMP packets. + Disable this option if you want to instantiate and use the + socket without root privileges and let the kernel handle ICMP + headers. Default to True. + Only available on Unix systems. Ignored on Windows. + + :raises SocketPermissionError: If the privileges are insufficient to + create the socket. + :raises SocketAddressError: If the requested address cannot be + assigned to the socket. + :raises ICMPSocketError: If another error occurs while creating the + socket. + + ''' + __slots__ = '_sock', '_address', '_privileged' + + _IP_VERSION = 6 + _ICMP_HEADER_OFFSET = 0 + _ICMP_HEADER_REAL_OFFSET = 40 + + _ICMP_CODE_OFFSET = _ICMP_HEADER_OFFSET + 1 + _ICMP_CHECKSUM_OFFSET = _ICMP_HEADER_OFFSET + 2 + _ICMP_ID_OFFSET = _ICMP_HEADER_OFFSET + 4 + _ICMP_SEQUENCE_OFFSET = _ICMP_HEADER_OFFSET + 6 + _ICMP_PAYLOAD_OFFSET = _ICMP_HEADER_OFFSET + 8 + + _ICMP_ECHO_REQUEST = 128 + _ICMP_ECHO_REPLY = 129 + + def _create_socket(self, type): + ''' + Create and return a new socket. + + ''' + return socket.socket( + family=socket.AF_INET6, + type=type, + proto=socket.IPPROTO_ICMPV6) + + def _set_ttl(self, ttl): + ''' + Set the time to live of every IP packet originating from this + socket. + + ''' + # Not available on macOS when the privileged param. is disabled + if PLATFORM_MACOS and not self._privileged: + return + + self._sock.setsockopt( + socket.IPPROTO_IPV6, + socket.IPV6_UNICAST_HOPS, + ttl) + + def _set_traffic_class(self, traffic_class): + ''' + Set the Traffic Class field of every IP packet originating from + this socket. + + Only available on Unix systems. Ignored on Windows. + + ''' + # Not available on Windows + if PLATFORM_WINDOWS: + return + + # Not available on macOS when the privileged param. is disabled + if PLATFORM_MACOS and not self._privileged: + return + + self._sock.setsockopt( + socket.IPPROTO_IPV6, + socket.IPV6_TCLASS, + traffic_class) + + +class AsyncSocket: + ''' + A wrapper for ICMP sockets which makes them asynchronous. + + :type icmp_sock: ICMPSocket + :param icmp_sock: An ICMP socket. Once the wrapper is instantiated, + this socket should no longer be used directly. + + ''' + __slots__ = '_icmp_sock' + + def __init__(self, icmp_sock): + self._icmp_sock = icmp_sock + self._icmp_sock.blocking = False + + def __getattr__(self, name): + ''' + Return the specified attribute of the underlying ICMP socket. + + ''' + if not self._icmp_sock: + raise SocketUnavailableError + + return getattr(self._icmp_sock, name) + + def __enter__(self): + ''' + Return this object. + + ''' + return self + + def __exit__(self, type, value, traceback): + ''' + Call the `close` method. + + ''' + self.close() + + def __del__(self): + ''' + Call the `close` method. + + ''' + self.close() + + async def receive(self, request=None, timeout=2): + ''' + Receive an ICMP reply message from the socket. + + This method can be called multiple times if you expect several + responses as with a broadcast address. + + This method is non-blocking. + + :type request: ICMPRequest, optional + :param request: The ICMP request to use to match the response. + By default, all ICMP packets arriving on the socket are + returned. + + :type timeout: int or float, optional + :param timeout: The maximum waiting time for receiving the + response in seconds. Default to 2. + + :rtype: ICMPReply + :returns: An `ICMPReply` object representing the response of the + desired destination or an upstream gateway. See the + `ICMPReply` class for details. + Unlike the `reveive` method of synchronous ICMP sockets, the + returned `ICMPReply` object does not specify the IP address + of the host that replied to the request message. + + :raises TimeoutExceeded: If no response is received before the + timeout specified in parameters. + :raises SocketUnavailableError: If the socket is closed. + :raises ICMPSocketError: If another error occurs while receiving. + + ''' + if not self._icmp_sock or not self._icmp_sock._sock: + raise SocketUnavailableError + + loop = asyncio.get_running_loop() + time_limit = time() + timeout + remaining_time = timeout + + try: + while True: + packet = await asyncio.wait_for( + loop.sock_recv(self._icmp_sock._sock, 1024), + remaining_time) + + current_time = time() + + if current_time > time_limit: + raise asyncio.TimeoutError + + reply = self._parse_reply( + packet=packet, + source=None, + current_time=current_time) + + if (reply and not request or + reply and request.id == reply.id and + request.sequence == reply.sequence): + return reply + + remaining_time = time_limit - current_time + + except asyncio.TimeoutError: + raise TimeoutExceeded(timeout) + + except OSError as err: + raise ICMPSocketError(str(err)) + + finally: + if isinstance(loop, asyncio.SelectorEventLoop): + loop.remove_reader(self._icmp_sock._sock) + + def detach(self): + ''' + Detach the socket from the wrapper and return it. The wrapper + cannot be used after this call but the socket can be reused for + other purposes. + + ''' + icmp_sock = self._icmp_sock + + if self._icmp_sock: + self._icmp_sock = None + + return icmp_sock + + def close(self): + ''' + Detach the underlying socket from the wrapper and close it. Both + cannot be used after this call. + + ''' + if self._icmp_sock: + self.detach().close() + + @property + def is_closed(self): + ''' + Indicate whether the underlying socket is closed or detached + from this wrapper. Return a `boolean`. + + ''' + return self._icmp_sock is None diff --git a/script.plexmod/lib/_included_packages/icmplib/traceroute.py b/script.plexmod/lib/_included_packages/icmplib/traceroute.py new file mode 100644 index 000000000..7dd45f876 --- /dev/null +++ b/script.plexmod/lib/_included_packages/icmplib/traceroute.py @@ -0,0 +1,212 @@ +''' + icmplib + ~~~~~~~ + + Easily forge ICMP packets and make your own ping and traceroute. + + https://github.com/ValentinBELYN/icmplib + + :copyright: Copyright 2017-2023 Valentin BELYN. + :license: GNU LGPLv3, see the LICENSE for details. + + ~~~~~~~ + + This program is free software: you can redistribute it and/or + modify it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this program. If not, see + . +''' + +from time import sleep + +from .sockets import ICMPv4Socket, ICMPv6Socket +from .models import ICMPRequest, Hop +from .exceptions import TimeExceeded, ICMPLibError +from .utils import * + + +def traceroute(address, count=2, interval=0.05, timeout=2, first_hop=1, + max_hops=30, fast=False, id=None, source=None, family=None, + **kwargs): + ''' + Determine the route to a destination host. + + The Internet is a large and complex aggregation of network hardware, + connected together by gateways. Tracking the route one's packets + follow can be difficult. This function uses the IP protocol time to + live field and attempts to elicit an ICMP Time Exceeded response + from each gateway along the path to some host. + + This function requires root privileges to run. + + :type address: str + :param address: The IP address, hostname or FQDN of the host to + reach. For deterministic behavior, prefer to use an IP address. + + :type count: int, optional + :param count: The number of ping to perform per hop. Default to 2. + + :type interval: int or float, optional + :param interval: The interval in seconds between sending each packet. + Default to 0.05. + + :type timeout: int or float, optional + :param timeout: The maximum waiting time for receiving a reply in + seconds. Default to 2. + + :type first_hop: int, optional + :param first_hop: The initial time to live value used in outgoing + probe packets. Default to 1. + + :type max_hops: int, optional + :param max_hops: The maximum time to live (max number of hops) used + in outgoing probe packets. Default to 30. + + :type fast: bool, optional + :param fast: When this option is enabled and an intermediate router + has been reached, skip to the next hop rather than perform + additional requests. The `count` parameter then becomes the + maximum number of requests in the event of no response. + Default to False. + + :type id: int, optional + :param id: The identifier of ICMP requests. Used to match the + responses with requests. In practice, a unique identifier should + be used for every traceroute process. The library handles this + identifier itself by default. + + :type source: str, optional + :param source: The IP address from which you want to send packets. + By default, the interface is automatically chosen according to + the specified destination. + + :type family: int, optional + :param family: The address family if a hostname or FQDN is specified. + Can be set to `4` for IPv4 or `6` for IPv6 addresses. By default, + this function searches for IPv4 addresses first before searching + for IPv6 addresses. + + Advanced (**kwags): + + :type payload: bytes, optional + :param payload: The payload content in bytes. A random payload is + used by default. + + :type payload_size: int, optional + :param payload_size: The payload size. Ignored when the `payload` + parameter is set. Default to 56. + + :type traffic_class: int, optional + :param traffic_class: The traffic class of ICMP packets. + Provides a defined level of service to packets by setting the DS + Field (formerly TOS) or the Traffic Class field of IP headers. + Packets are delivered with the minimum priority by default + (Best-effort delivery). + Intermediate routers must be able to support this feature. + Only available on Unix systems. Ignored on Windows. + + :rtype: list[Hop] + :returns: A list of `Hop` objects representing the route to the + desired destination. The list is sorted in ascending order + according to the distance, in terms of hops, that separates the + remote host from the current machine. Gateways that do not + respond to requests are not added to this list. + + :raises NameLookupError: If you pass a hostname or FQDN in + parameters and it does not exist or cannot be resolved. + :raises SocketPermissionError: If the privileges are insufficient to + create the socket. + :raises SocketAddressError: If the source address cannot be assigned + to the socket. + :raises ICMPSocketError: If another error occurs. See the + `ICMPv4Socket` or `ICMPv6Socket` class for details. + + Usage:: + + >>> from icmplib import traceroute + >>> hops = traceroute('1.1.1.1') + >>> last_distance = 0 + + >>> for hop in hops: + ... if last_distance + 1 != hop.distance: + ... print('Some gateways are not responding') + ... + ... print(f'{hop.distance} {hop.address} {hop.avg_rtt} ms') + ... + ... last_distance = hop.distance + + 1 10.0.0.1 5.196 ms + 2 194.149.169.49 7.552 ms + 3 194.149.166.54 12.21 ms + * Some gateways are not responding + 5 212.73.205.22 22.15 ms + 6 1.1.1.1 13.59 ms + + See the `Hop` class for details. + + ''' + if is_hostname(address): + address = resolve(address, family)[0] + + if is_ipv6_address(address): + _Socket = ICMPv6Socket + else: + _Socket = ICMPv4Socket + + id = id or unique_identifier() + ttl = first_hop + host_reached = False + hops = [] + + with _Socket(source) as sock: + while not host_reached and ttl <= max_hops: + reply = None + packets_sent = 0 + rtts = [] + + for sequence in range(count): + request = ICMPRequest( + destination=address, + id=id, + sequence=sequence, + ttl=ttl, + **kwargs) + + try: + sock.send(request) + packets_sent += 1 + + reply = sock.receive(request, timeout) + rtt = (reply.time - request.time) * 1000 + rtts.append(rtt) + + reply.raise_for_status() + host_reached = True + + except TimeExceeded: + sleep(interval) + + except ICMPLibError: + break + + if reply: + hop = Hop( + address=reply.source, + packets_sent=packets_sent, + rtts=rtts, + distance=ttl) + + hops.append(hop) + + ttl += 1 + + return hops diff --git a/script.plexmod/lib/_included_packages/icmplib/utils.py b/script.plexmod/lib/_included_packages/icmplib/utils.py new file mode 100644 index 000000000..00e2664db --- /dev/null +++ b/script.plexmod/lib/_included_packages/icmplib/utils.py @@ -0,0 +1,199 @@ +''' + icmplib + ~~~~~~~ + + Easily forge ICMP packets and make your own ping and traceroute. + + https://github.com/ValentinBELYN/icmplib + + :copyright: Copyright 2017-2023 Valentin BELYN. + :license: GNU LGPLv3, see the LICENSE for details. + + ~~~~~~~ + + This program is free software: you can redistribute it and/or + modify it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this program. If not, see + . +''' + +import socket, asyncio + +from threading import Lock +from sys import platform +from os import getpid +from re import match +from random import choices + +from .exceptions import NameLookupError + + +PID = getpid() +PLATFORM_LINUX = platform == 'linux' +PLATFORM_MACOS = platform == 'darwin' +PLATFORM_WINDOWS = platform == 'win32' + +_lock_id = Lock() +_current_id = PID + + +def random_byte_message(size): + ''' + Generate a random byte sequence of the specified size. + + ''' + sequence = choices( + b'abcdefghijklmnopqrstuvwxyz' + b'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + b'1234567890', k=size) + + return bytes(sequence) + + +def unique_identifier(): + ''' + Generate a unique identifier between 0 and 65535. + The first number generated will be equal to the PID + 1. + + ''' + global _current_id + + with _lock_id: + _current_id += 1 + _current_id &= 0xffff + + return _current_id + + +def resolve(name, family=None): + ''' + Resolve a hostname or FQDN to an IP address. Depending on the name + specified in parameters, several IP addresses may be returned. + + This function relies on the DNS name server configured on your + operating system. + + :type name: str + :param name: A hostname or a Fully Qualified Domain Name (FQDN). + + :type family: int, optional + :param family: The address family. Can be set to `4` for IPv4 or `6` + for IPv6 addresses. By default, this function searches for IPv4 + addresses first for compatibility reasons (A DNS lookup) before + searching for IPv6 addresses (AAAA DNS lookup). + + :rtype: list[str] + :returns: A list of IP addresses corresponding to the name passed as + a parameter. + + :raises NameLookupError: If the requested name does not exist or + cannot be resolved. + + ''' + try: + if family == 6: + _family = socket.AF_INET6 + else: + _family = socket.AF_INET + + lookup = socket.getaddrinfo( + host=name, + port=None, + family=_family, + type=socket.SOCK_DGRAM) + + return [address[4][0] for address in lookup] + + except OSError: + if not family: + return resolve(name, 6) + + raise NameLookupError(name) + + +async def async_resolve(name, family=None): + ''' + Resolve a hostname or FQDN to an IP address. Depending on the name + specified in parameters, several IP addresses may be returned. + + This function relies on the DNS name server configured on your + operating system. + + This function is non-blocking. + + :type name: str + :param name: A hostname or a Fully Qualified Domain Name (FQDN). + + :type family: int, optional + :param family: The address family. Can be set to `4` for IPv4 or `6` + for IPv6 addresses. By default, this function searches for IPv4 + addresses first for compatibility reasons (A DNS lookup) before + searching for IPv6 addresses (AAAA DNS lookup). + + :rtype: list[str] + :returns: A list of IP addresses corresponding to the name passed as + a parameter. + + :raises NameLookupError: If the requested name does not exist or + cannot be resolved. + + ''' + try: + if family == 6: + _family = socket.AF_INET6 + else: + _family = socket.AF_INET + + loop = asyncio.get_running_loop() + + lookup = await loop.getaddrinfo( + host=name, + port=None, + family=_family, + type=socket.SOCK_DGRAM) + + return [address[4][0] for address in lookup] + + except OSError: + if not family: + return await async_resolve(name, 6) + + raise NameLookupError(name) + + +def is_hostname(name): + ''' + Indicate whether the specified name is a hostname or an FQDN. + Return a `boolean`. + + ''' + pattern = r'(?i)^([a-z0-9-]+|([a-z0-9_-]+[.])+[a-z]+)$' + return match(pattern, name) is not None + + +def is_ipv4_address(address): + ''' + Indicate whether the specified address is an IPv4 address. + Return a `boolean`. + + ''' + pattern = r'^([0-9]{1,3}[.]){3}[0-9]{1,3}$' + return match(pattern, address) is not None + + +def is_ipv6_address(address): + ''' + Indicate whether the specified address is an IPv6 address. + Return a `boolean`. + + ''' + return ':' in address diff --git a/script.plexmod/lib/_included_packages/plexnet/__init__.py b/script.plexmod/lib/_included_packages/plexnet/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/script.plexmod/lib/_included_packages/plexnet/asyncadapter.py b/script.plexmod/lib/_included_packages/plexnet/asyncadapter.py new file mode 100644 index 000000000..97fbcd741 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/asyncadapter.py @@ -0,0 +1,338 @@ +from __future__ import absolute_import +import time +import socket +import six + +import requests +import six + +from requests.packages.urllib3 import HTTPConnectionPool, HTTPSConnectionPool +from requests.packages.urllib3.connection import HTTPConnection +from requests.packages.urllib3.poolmanager import PoolManager, proxy_from_url +try: + from requests.packages.urllib3.connectionpool import VerifiedHTTPSConnection +except ImportError: + # urllib3 >= 2.1.0 + from requests.packages.urllib3.connection import HTTPSConnection as VerifiedHTTPSConnection + +from requests.adapters import HTTPAdapter +from requests.compat import urlparse + +#from six.moves.http_client import HTTPConnection +import errno + +DEFAULT_POOLBLOCK = False +SSL_KEYWORDS = ('key_file', 'cert_file', 'cert_reqs', 'ca_certs', + 'ssl_version') + +WIN_WSAEINVAL = 10022 +WIN_EWOULDBLOCK = 10035 +WIN_ECONNRESET = 10054 +WIN_EISCONN = 10056 +WIN_ENOTCONN = 10057 +WIN_EHOSTUNREACH = 10065 + + +def ABORT_FLAG_FUNCTION(): + return False + + +class TimeoutException(Exception): + pass + + +class CanceledException(Exception): + pass + + +class AsyncTimeout(float): + def __repr__(self): + return '{0}({1})'.format(float(self), self.getConnectTimeout()) + + def __str__(self): + return repr(self) + + @classmethod + def fromTimeout(cls, t): + if isinstance(t, AsyncTimeout): + return t + + try: + return AsyncTimeout(float(t)) or DEFAULT_TIMEOUT + except TypeError: + return DEFAULT_TIMEOUT + + def setConnectTimeout(self, val): + self._connectTimout = val + return self + + def getConnectTimeout(self): + if hasattr(self, '_connectTimout'): + return self._connectTimout + + return self + + +DEFAULT_TIMEOUT = AsyncTimeout(10).setConnectTimeout(10) + + +class AsyncVerifiedHTTPSConnection(VerifiedHTTPSConnection): + def __init__(self, *args, **kwargs): + VerifiedHTTPSConnection.__init__(self, *args, **kwargs) + self._canceled = False + self.deadline = 0 + self._timeout = AsyncTimeout(DEFAULT_TIMEOUT) + + def _check_timeout(self): + if time.time() > self.deadline: + raise TimeoutException('connection timed out') + + def create_connection(self, address, timeout=None, source_address=None): + """Connect to *address* and return the socket object. + + Convenience function. Connect to *address* (a 2-tuple ``(host, + port)``) and return the socket object. Passing the optional + *timeout* parameter will set the timeout on the socket instance + before attempting to connect. If no *timeout* is supplied, the + global default timeout setting returned by :func:`getdefaulttimeout` + is used. If *source_address* is set it must be a tuple of (host, port) + for the socket to bind as a source address before making the connection. + An host of '' or port 0 tells the OS to use the default. + """ + timeout = AsyncTimeout.fromTimeout(timeout) + self._timeout = timeout + + host, port = address + err = None + for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + sock = None + try: + sock = socket.socket(af, socktype, proto) + sock.setblocking(False) # this is obviously critical + self.deadline = time.time() + timeout.getConnectTimeout() + # sock.settimeout(timeout) + + if source_address: + sock.bind(source_address) + for msg in self._connect(sock, sa): + if self._canceled or ABORT_FLAG_FUNCTION(): + raise CanceledException('Request canceled') + sock.setblocking(True) + return sock + + except socket.error as _: + err = _ + if sock is not None: + sock.shutdown(socket.SHUT_RDWR) + sock.close() + + if err is not None: + raise err + else: + raise socket.error("getaddrinfo returns an empty list") + + def _connect(self, sock, sa): + while not self._canceled and not ABORT_FLAG_FUNCTION(): + time.sleep(0.01) + self._check_timeout() # this should be done at the beginning of each loop + status = sock.connect_ex(sa) + if not status or status in (errno.EISCONN, WIN_EISCONN): + break + elif status in (errno.EINPROGRESS, WIN_EWOULDBLOCK): + self.deadline = time.time() + self._timeout.getConnectTimeout() + # elif status in (errno.EWOULDBLOCK, errno.EALREADY) or (os.name == 'nt' and status == errno.WSAEINVAL): + # pass + yield + + if self._canceled or ABORT_FLAG_FUNCTION(): + raise CanceledException('Request canceled') + + error = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) + if error: + # TODO: determine when this case can actually happen + raise socket.error((error,)) + + def _new_conn(self): + sock = self.create_connection( + address=(self.host, self.port), + timeout=self.timeout + ) + + return sock + + def cancel(self): + self._canceled = True + + +class AsyncHTTPConnection(HTTPConnection): + def __init__(self, *args, **kwargs): + HTTPConnection.__init__(self, *args, **kwargs) + self._canceled = False + self.deadline = 0 + + def cancel(self): + self._canceled = True + + +class AsyncHTTPConnectionPool(HTTPConnectionPool): + def __init__(self, *args, **kwargs): + HTTPConnectionPool.__init__(self, *args, **kwargs) + self.connections = [] + + def _new_conn(self): + """ + Return a fresh :class:`httplib.HTTPConnection`. + """ + self.num_connections += 1 + + extra_params = {} + if six.PY2: + extra_params['strict'] = self.strict + + conn = AsyncHTTPConnection(host=self.host, port=self.port, timeout=self.timeout.connect_timeout, **extra_params) + + # Backport fix LP #1412545 + if getattr(conn, '_tunnel_host', None): + # TODO: Fix tunnel so it doesn't depend on self.sock state. + conn._tunnel() + # Mark this connection as not reusable + conn.auto_open = 0 + + self.connections.append(conn) + + return conn + + def cancel(self): + for c in self.connections: + c.cancel() + + +class AsyncHTTPSConnectionPool(HTTPSConnectionPool): + def __init__(self, *args, **kwargs): + HTTPSConnectionPool.__init__(self, *args, **kwargs) + self.connections = [] + + def _new_conn(self): + """ + Return a fresh :class:`httplib.HTTPSConnection`. + """ + self.num_connections += 1 + + actual_host = self.host + actual_port = self.port + if self.proxy is not None: + actual_host = self.proxy.host + actual_port = self.proxy.port + + connection_class = AsyncVerifiedHTTPSConnection + + extra_params = {} + if six.PY2: + extra_params['strict'] = self.strict + connection = connection_class(host=actual_host, port=actual_port, timeout=self.timeout.connect_timeout, **extra_params) + + self.connections.append(connection) + + try: + return self._prepare_conn(connection) + except AttributeError: + # urllib3 2.1.0 + return connection + + def cancel(self): + for c in self.connections: + c.cancel() + + +pool_classes_by_scheme = { + 'http': AsyncHTTPConnectionPool, + 'https': AsyncHTTPSConnectionPool, +} + + +class AsyncPoolManager(PoolManager): + def _new_pool(self, scheme, host, port, request_context=None): + """ + Create a new :class:`ConnectionPool` based on host, port and scheme. + + This method is used to actually create the connection pools handed out + by :meth:`connection_from_url` and companion methods. It is intended + to be overridden for customization. + """ + pool_cls = pool_classes_by_scheme[scheme] + kwargs = self.connection_pool_kw + if scheme == 'http': + kwargs = self.connection_pool_kw.copy() + for kw in SSL_KEYWORDS: + kwargs.pop(kw, None) + + return pool_cls(host, port, **kwargs) + + +class AsyncHTTPAdapter(HTTPAdapter): + def cancel(self): + for c in self.connections: + c.cancel() + + def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK): + """Initializes a urllib3 PoolManager. This method should not be called + from user code, and is only exposed for use when subclassing the + :class:`HTTPAdapter `. + + :param connections: The number of urllib3 connection pools to cache. + :param maxsize: The maximum number of connections to save in the pool. + :param block: Block when no free connections are available. + """ + # save these values for pickling + self._pool_connections = connections + self._pool_maxsize = maxsize + self._pool_block = block + + self.poolmanager = AsyncPoolManager(num_pools=connections, maxsize=maxsize, block=block) + self.connections = [] + + def get_connection(self, url, proxies=None): + """Returns a urllib3 connection for the given URL. This should not be + called from user code, and is only exposed for use when subclassing the + :class:`HTTPAdapter `. + + :param url: The URL to connect to. + :param proxies: (optional) A Requests-style dictionary of proxies used on this request. + """ + proxies = proxies or {} + proxy = proxies.get(urlparse(url.lower()).scheme) + + if proxy: + proxy_headers = self.proxy_headers(proxy) + + if proxy not in self.proxy_manager: + self.proxy_manager[proxy] = proxy_from_url( + proxy, + proxy_headers=proxy_headers, + num_pools=self._pool_connections, + maxsize=self._pool_maxsize, + block=self._pool_block + ) + + conn = self.proxy_manager[proxy].connection_from_url(url) + else: + # Only scheme should be lower case + parsed = urlparse(url) + url = parsed.geturl() + conn = self.poolmanager.connection_from_url(url) + + self.connections.append(conn) + return conn + + +class Session(requests.Session): + def __init__(self, *args, **kwargs): + requests.Session.__init__(self, *args, **kwargs) + self.mount('https://', AsyncHTTPAdapter()) + self.mount('http://', AsyncHTTPAdapter()) + + def cancel(self): + for v in self.adapters.values(): + v.close() + v.cancel() diff --git a/script.plexmod/lib/_included_packages/plexnet/audio.py b/script.plexmod/lib/_included_packages/plexnet/audio.py new file mode 100644 index 000000000..251c19b5e --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/audio.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +from . import plexobjects +from . import plexmedia +from . import media + + +class Audio(media.MediaItem): + def __init__(self, *args, **kwargs): + self._settings = None + media.MediaItem.__init__(self, *args, **kwargs) + + def __eq__(self, other): + return self.ratingKey == other.ratingKey + + def __ne__(self, other): + return not self.__eq__(other) + + def _setData(self, data): + for k, v in data.attrib.items(): + setattr(self, k, plexobjects.PlexValue(v, self)) + + self.key = plexobjects.PlexValue(self.key.replace('/children', ''), self) + + def isMusicItem(self): + return True + + +@plexobjects.registerLibType +class Artist(Audio): + TYPE = 'artist' + + def _setData(self, data): + Audio._setData(self, data) + if self.isFullObject(): + self.countries = plexobjects.PlexItemList(data, media.Country, media.Country.TYPE, server=self.server) + self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server) + self.similar = plexobjects.PlexItemList(data, media.Similar, media.Similar.TYPE, server=self.server) + + def albums(self): + path = '%s/children' % self.key + return plexobjects.listItems(self.server, path, Album.TYPE) + + def album(self, title): + path = '%s/children' % self.key + return plexobjects.findItem(self.server, path, title) + + def tracks(self, watched=None): + leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey + return plexobjects.listItems(self.server, leavesKey, watched=watched) + + def all(self): + return self.tracks() + + def track(self, title): + path = '/library/metadata/%s/allLeaves' % self.ratingKey + return plexobjects.findItem(self.server, path, title) + + def isFullObject(self): + # plex bug? http://bit.ly/1Sc2J3V + fixed_key = self.key.replace('/children', '') + return self.initpath == fixed_key + + def refresh(self): + self.server.query('/library/metadata/%s/refresh' % self.ratingKey) + + +@plexobjects.registerLibType +class Album(Audio): + TYPE = 'album' + + def _setData(self, data): + Audio._setData(self, data) + if self.isFullObject(): + self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server) + + @property + def defaultTitle(self): + return self.parentTitle or self.title + + def tracks(self, watched=None): + path = '%s/children' % self.key + return plexobjects.listItems(self.server, path, watched=watched) + + def track(self, title): + path = '%s/children' % self.key + return plexobjects.findItem(self.server, path, title) + + def all(self): + return self.tracks() + + def isFullObject(self): + # plex bug? http://bit.ly/1Sc2J3V + fixed_key = self.key.replace('/children', '') + return self.initpath == fixed_key + + def artist(self): + return plexobjects.listItems(self.server, self.parentKey)[0] + + def watched(self): + return self.tracks(watched=True) + + def unwatched(self): + return self.tracks(watched=False) + + +@plexobjects.registerLibType +class Track(Audio): + TYPE = 'track' + + def _setData(self, data): + Audio._setData(self, data) + if self.isFullObject(): + self.moods = plexobjects.PlexItemList(data, media.Mood, media.Mood.TYPE, server=self.server) + self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self) + + # data for active sessions + self.user = self._findUser(data) + self.player = self._findPlayer(data) + self.transcodeSession = self._findTranscodeSession(data) + + @property + def defaultTitle(self): + return self.parentTitle or self.title + + @property + def settings(self): + if not self._settings: + from . import plexapp + self._settings = plexapp.PlayerSettingsInterface() + + return self._settings + + @property + def thumbUrl(self): + return self.server.url(self.parentThumb) + + def album(self): + return plexobjects.listItems(self.server, self.parentKey)[0] + + def artist(self): + return plexobjects.listItems(self.server, self.grandparentKey)[0] + + def getStreamURL(self, **params): + return self._getStreamURL(**params) + + @property + def defaultThumb(self): + return self.__dict__.get('thumb') or self.__dict__.get('parentThumb') or self.get('grandparentThumb') + + @property + def defaultArt(self): + return self.__dict__.get('art') or self.get('grandparentArt') diff --git a/script.plexmod/lib/_included_packages/plexnet/audioobject.py b/script.plexmod/lib/_included_packages/plexnet/audioobject.py new file mode 100644 index 000000000..453e5e604 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/audioobject.py @@ -0,0 +1,83 @@ +from __future__ import absolute_import +from . import http +from . import mediadecisionengine +from . import util + + +class AudioObjectClass(object): + def __init__(self, item): + self.containerFormats = { + 'aac': "es.aac-adts" + } + + self.item = item + self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item) + if self.choice: + self.media = self.choice.media + self.lyrics = None # createLyrics(item, self.media) + + def build(self, directPlay=None): + directPlay = directPlay or self.choice.isDirectPlayable + + obj = util.AttributeDict() + + # TODO(schuyler): Do we want/need to add anything generic here? Title? Duration? + + if directPlay: + obj = self.buildDirectPlay(obj) + else: + obj = self.buildTranscode(obj) + + self.metadata = obj + + util.LOG("Constructed audio item for playback: {0}".format(obj)) + + return self.metadata + + def buildTranscode(self, obj): + transcodeServer = self.item.getTranscodeServer(True, "audio") + if not transcodeServer: + return None + + obj.streamFormat = "mp3" + obj.isTranscoded = True + obj.transcodeServer = transcodeServer + + builder = http.HttpRequest(transcodeServer.buildUrl("/music/:/transcode/universal/start.m3u8", True)) + builder.addParam("protocol", "http") + builder.addParam("path", self.item.getAbsolutePath("key")) + builder.addParam("session", self.item.getGlobal("clientIdentifier")) + builder.addParam("directPlay", "0") + builder.addParam("directStream", "0") + + obj.url = builder.getUrl() + + return obj + + def buildDirectPlay(self, obj): + if self.choice.part: + obj.url = self.item.getServer().buildUrl(self.choice.part.getAbsolutePath("key"), True) + + # Set and override the stream format if applicable + obj.streamFormat = self.choice.media.container or 'mp3' + if self.containerFormats.get(obj.streamFormat): + obj.streamFormat = self.containerFormats[obj.streamFormat] + + # If we're direct playing a FLAC, bitrate can be required, and supposedly + # this is the only way to do it. plexinc/roku-client#48 + # + bitrate = self.choice.media.getInt("bitrate") + if bitrate > 0: + obj.streams = [{'url': obj.url, 'bitrate': bitrate}] + + return obj + + # We may as well fallback to transcoding if we could not direct play + return self.buildTranscode(obj) + + def getLyrics(self): + return self.lyrics + + def hasLyrics(self): + return False + # return self.lyrics.isAvailable() diff --git a/script.plexmod/lib/_included_packages/plexnet/callback.py b/script.plexmod/lib/_included_packages/plexnet/callback.py new file mode 100644 index 000000000..d99a86f50 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/callback.py @@ -0,0 +1,54 @@ +from __future__ import absolute_import +import threading + + +class Callable(object): + _currID = 0 + + def __init__(self, func, forcedArgs=None, ID=None): + self.func = func + self.forcedArgs = forcedArgs + + self.ID = ID or id(func) + + if not self.ID: + self.ID = Callable.nextID() + + def __repr__(self): + return ''.format(repr(self.func).strip('<>')) + + def __eq__(self, other): + if not other: + return False + + if self.__class__ != other.__class__: + return False + + return self.ID and self.ID == other.ID + + def __ne__(self, other): + return not self.__eq__(other) + + def __call__(self, *args, **kwargs): + args = args or [] + if self.forcedArgs: + args = self.forcedArgs + + self.func(*args, **kwargs) + + @property + def context(self): + return self.func.__self__ + + @classmethod + def nextID(cls): + cls._currID += 1 + return cls._currID + + def deferCall(self, timeout=0.1): + timer = threading.Timer(timeout, self.onDeferCallTimer) + timer.name = 'ONDEFERCALLBACK-TIMER:{0}'.format(self.func) + timer.start() + + def onDeferCallTimer(self): + self() diff --git a/script.plexmod/lib/_included_packages/plexnet/captions.py b/script.plexmod/lib/_included_packages/plexnet/captions.py new file mode 100644 index 000000000..e3b49fe21 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/captions.py @@ -0,0 +1,80 @@ +from __future__ import absolute_import +from . import plexapp +from . import util + + +class Captions(object): + def __init__(self): + self.deviceInfo = util.INTERFACE.getGlobal("deviceInfo") + + self.textSize = util.AttributeDict({ + 'extrasmall': 15, + 'small': 20, + 'medium': 30, + 'large': 45, + 'extralarge': 65, + }) + + self.burnedSize = util.AttributeDict({ + 'extrasmall': "60", + 'small': "80", + 'medium': "100", + 'large': "135", + 'extralarge': "200" + }) + + self.colors = util.AttributeDict({ + 'white': 0xffffffff, + 'black': 0x000000ff, + 'red': 0xff0000ff, + 'green': 0x008000ff, + 'blue': 0x0000ffff, + 'yellow': 0xffff00ff, + 'magenta': 0xff00ffff, + 'cyan': 0x00ffffff, + }) + + self.defaults = util.AttributeDict({ + 'textSize': self.textSize.medium, + 'textColor': self.colors.white, + 'textOpacity': 80, + 'backgroundColor': self.colors.black, + 'backgroundOpacity': 70, + 'burnedSize': None + }) + + def getTextSize(self): + value = self.getOption("Text/Size") + return self.textSize.get(value) or self.defaults.textSize + + def getTextColor(self): + value = self.getOption("Text/Color") + return self.colors.get(value) or self.defaults.textColor + + def getTextOpacity(self): + value = self.getOption("Text/Opacity") + if value is None or value == "default": + return self.defaults.textOpacity + else: + return int(value) + + def getBackgroundColor(self): + value = self.getOption("Background/Color") + return self.colors.get(value) or self.defaults.backgroundColor + + def getBackgroundOpacity(self): + value = self.getOption("Background/Opacity") + if value is None or value == "default": + return self.defaults.backgroundOpacity + else: + return int(value) + + def getBurnedSize(self): + value = self.getOption("Text/Size") + return self.burnedSize.get(value) or self.defaults.burnedSize + + def getOption(self, key): + opt = self.deviceInfo.getCaptionsOption(key) + return opt is not None and opt.lower().replace(' ', '') or None + +CAPTIONS = Captions() diff --git a/script.plexmod/lib/_included_packages/plexnet/compat.py b/script.plexmod/lib/_included_packages/plexnet/compat.py new file mode 100644 index 000000000..96b0d7fce --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/compat.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +""" +Python 2/3 compatability +Always try Py3 first +""" + +from __future__ import absolute_import +try: + from urllib.parse import urlencode +except ImportError: + from six.moves.urllib.parse import urlencode + +try: + from urllib.parse import quote +except ImportError: + from six.moves.urllib.parse import quote + +try: + from urllib.parse import quote_plus +except ImportError: + from six.moves.urllib.parse import quote_plus + +try: + from configparser import ConfigParser +except ImportError: + from six.moves.configparser import ConfigParser diff --git a/script.plexmod/lib/_included_packages/plexnet/exceptions.py b/script.plexmod/lib/_included_packages/plexnet/exceptions.py new file mode 100644 index 000000000..a3adf39e8 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/exceptions.py @@ -0,0 +1,22 @@ +class BadRequest(Exception): + pass + + +class NotFound(Exception): + pass + + +class UnknownType(Exception): + pass + + +class Unsupported(Exception): + pass + + +class Unauthorized(Exception): + pass + + +class ServerNotOwned(Exception): + pass diff --git a/script.plexmod/lib/_included_packages/plexnet/gdm.py b/script.plexmod/lib/_included_packages/plexnet/gdm.py new file mode 100644 index 000000000..90b9eba7a --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/gdm.py @@ -0,0 +1,347 @@ +from __future__ import absolute_import +import threading +import socket +import traceback +import time +from . import util +from . import netif + +from . import plexconnection + +DISCOVERY_PORT = 32414 +WIN_NL = chr(13) + chr(10) + + +class GDMDiscovery(object): + def __init__(self): + self._close = False + self.thread = None + + # def isActive(self): + # util.LOG('GDMDiscovery().isActive() - NOT IMPLEMENTED') + # return False + + # def discover(self): + # util.LOG('GDMDiscovery().discover() - NOT IMPLEMENTED') + + def isActive(self): + from . import plexapp + return util.INTERFACE.getPreference("gdm_discovery", True) and self.thread and self.thread.is_alive() + + ''' + def discover(self): + # Only allow discovery if enabled and not currently running + self._close = False + import plexapp + if not util.INTERFACE.getPreference("gdm_discovery", True) or self.isActive(): + return + + ifaces = netif.getInterfaces() + + message = "M-SEARCH * HTTP/1.1" + WIN_NL + WIN_NL + + # Broadcasting to 255.255.255.255 only works on some Rokus, but we + # can't reliably determine the broadcast address for our current + # interface. Try assuming a /24 network, and then fall back to the + # multicast address if that doesn't work. + + multicast = "239.0.0.250" + ip = multicast + subnetRegex = re.compile("((\d+)\.(\d+)\.(\d+)\.)(\d+)") + addr = getFirstIPAddress() # TODO:: -------------------------------------------------------------------------------------------------------- HANDLE + if addr: + match = subnetRegex.search(addr) + if match: + ip = match.group(1) + "255" + util.DEBUG_LOG("Using broadcast address {0}".format()) + + # Socket things sometimes fail for no good reason, so try a few times. + attempt = 0 + success = False + + while attempt < 5 and not success: + udp = CreateObject("roDatagramSocket") + udp.setMessagePort(Application().port) + udp.setBroadcast(true) + + # More things that have been observed to be flaky. + for i in range(5): + addr = CreateObject("roSocketAddress") + addr.setHostName(ip) + addr.setPort(32414) + udp.setSendToAddress(addr) + + sendTo = udp.getSendToAddress() + if sendTo: + sendToStr = str(sendTo.getAddress()) + addrStr = str(addr.getAddress()) + util.DEBUG_LOG("GDM sendto address: " + sendToStr + " / " + addrStr) + if sendToStr == addrStr: + break + + util.ERROR_LOG("Failed to set GDM sendto address") + + udp.notifyReadable(true) + bytesSent = udp.sendStr(message) + util.DEBUG_LOG("Sent " + str(bytesSent) + " bytes") + if bytesSent > 0: + success = udp.eOK() + else: + success = False + if bytesSent == 0 and ip != multicast: + util.LOG("Falling back to multicast address") + ip = multicast + attempt = 0 + + if success: + break + elif attempt == 4 and ip != multicast: + util.LOG("Falling back to multicast address") + ip = multicast + attempt = 0 + else: + time.sleep(500) + util.WARN_LOG("Retrying GDM, errno=" + str(udp.status())) + attempt += 1 + + if success: + util.DEBUG_LOG("Successfully sent GDM discovery message, waiting for servers") + self.servers = [] + self.timer = plexapp.createTimer(5000, self.onTimer) + self.socket = udp + Application().AddSocketCallback(udp, createCallable("OnSocketEvent", m)) + util.APP.addTimer(self.timer) + else: + util.ERROR_LOG("Failed to send GDM discovery message") + import plexapp + import plexresource + plexapp.SERVERMANAGER.UpdateFromConnectionType([], plexresource.ResourceConnection.SOURCE_DISCOVERED) + self.socket = None + self.timer = None + ''' + + def discover(self): + from . import plexapp + if not util.INTERFACE.getPreference("gdm_discovery", True) or self.isActive(): + return + + self.thread = threading.Thread(target=self._discover) + self.thread.start() + + def _discover(self): + ifaces = netif.getInterfaces() + sockets = [] + self.servers = [] + + packet = ("M-SEARCH * HTTP/1.1" + WIN_NL + WIN_NL).encode("utf-8") + + for i in ifaces: + if not i.broadcast: + continue + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.01) # 10ms + s.bind((i.ip, 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sockets.append((s, i)) + + success = False + + for attempt in (0, 1): + for s, i in sockets: + if self._close: + return + util.DEBUG_LOG(' o-> Broadcasting to {0}: {1}'.format(i.name, i.broadcast)) + try: + s.sendto(packet, (i.broadcast, DISCOVERY_PORT)) + success = True + except: + util.ERROR() + + if success: + break + + end = time.time() + 5 + + while time.time() < end: + for s, i in sockets: + if self._close: + return + try: + message, address = s.recvfrom(4096) + self.onSocketEvent(message, address) + except socket.timeout: + pass + except: + traceback.print_exc() + + self.discoveryFinished() + + def onSocketEvent(self, message, addr): + util.DEBUG_LOG('Received GDM message:\n' + str(message)) + + hostname = addr[0] # socket.gethostbyaddr(addr[0])[0] + + name = parseFieldValue(message, b"Name: ") + port = parseFieldValue(message, b"Port: ") or b"32400" + machineID = parseFieldValue(message, b"Resource-Identifier: ") + secureHost = parseFieldValue(message, b"Host: ") + + util.DEBUG_LOG("Received GDM response for " + repr(name) + " at http://" + hostname + ":" + port) + + if not name or not machineID: + return + + from . import plexserver + conn = plexconnection.PlexConnection(plexconnection.PlexConnection.SOURCE_DISCOVERED, "http://" + hostname + ":" + port, True, None, bool(secureHost)) + server = plexserver.createPlexServerForConnection(conn) + server.uuid = machineID + server.name = name + server.sameNetwork = True + + # If the server advertised a secure hostname, add a secure connection as well, and + # set the http connection as a fallback. + # + if secureHost: + server.connections.insert( + 0, + plexconnection.PlexConnection( + plexconnection.PlexConnection.SOURCE_DISCOVERED, "https://" + hostname.replace(".", "-") + "." + secureHost + ":" + port, True, None + ) + ) + + self.servers.append(server) + + def discoveryFinished(self, *args, **kwargs): + # Time's up, report whatever we found + self.close() + + if self.servers: + util.LOG("Finished GDM discovery, found {0} server(s)".format(len(self.servers))) + from . import plexapp + plexapp.SERVERMANAGER.updateFromConnectionType(self.servers, plexconnection.PlexConnection.SOURCE_DISCOVERED) + self.servers = None + + def close(self): + self._close = True + + +def parseFieldValue(message, label): + if label not in message: + return None + + return message.split(label, 1)[-1].split(chr(13).encode())[0].decode() + + +DISCOVERY = GDMDiscovery() + +''' +# GDM Advertising + +class GDMAdvertiser(object): + + def __init__(self): + self.responseString = None + + def createSocket() + listenAddr = CreateObject("roSocketAddress") + listenAddr.setPort(32412) + listenAddr.setAddress("0.0.0.0") + + udp = CreateObject("roDatagramSocket") + + if not udp.setAddress(listenAddr) then + Error("Failed to set address on GDM advertiser socket") + return + end if + + if not udp.setBroadcast(true) then + Error("Failed to set broadcast on GDM advertiser socket") + return + end if + + udp.notifyReadable(true) + udp.setMessagePort(Application().port) + + m.socket = udp + + Application().AddSocketCallback(udp, createCallable("OnSocketEvent", m)) + + Debug("Created GDM player advertiser") + + + def refresh() + # Always regenerate our response, even if it might not have changed, it's + # just not that expensive. + m.responseString = invalid + + enabled = AppSettings().GetBoolPreference("remotecontrol") + if enabled AND m.socket = invalid then + m.CreateSocket() + else if not enabled AND m.socket <> invalid then + m.Close() + end if + + + def cleanup() + m.Close() + fn = function() :m.GDMAdvertiser = invalid : + fn() + + + def onSocketEvent(msg as object) + # PMS polls every five seconds, so this is chatty when not debugging. + # Debug("Got a GDM advertiser socket event, is readable: " + tostr(m.socket.isReadable())) + + if m.socket.isReadable() then + message = m.socket.receiveStr(4096) + endIndex = instr(1, message, chr(13)) - 1 + if endIndex <= 0 then endIndex = message.Len() + line = Mid(message, 1, endIndex) + + if line = "M-SEARCH * HTTP/1.1" then + response = m.GetResponseString() + + # Respond directly to whoever sent the search message. + sock = CreateObject("roDatagramSocket") + sock.setSendToAddress(m.socket.getReceivedFromAddress()) + bytesSent = sock.sendStr(response) + sock.Close() + if bytesSent <> Len(response) then + Error("GDM player response only sent " + tostr(bytesSent) + " bytes out of " + tostr(Len(response))) + end if + else + Error("Received unexpected message on GDM advertiser socket: " + tostr(line) + ";") + end if + end if + + + def getResponseString() as string + if m.responseString = invalid then + buf = box("HTTP/1.0 200 OK" + WinNL()) + + settings = AppSettings() + + appendNameValue(buf, "Name", settings.GetGlobal("friendlyName")) + appendNameValue(buf, "Port", WebServer().port.tostr()) + appendNameValue(buf, "Product", "Plex for Roku") + appendNameValue(buf, "Content-Type", "plex/media-player") + appendNameValue(buf, "Protocol", "plex") + appendNameValue(buf, "Protocol-Version", "1") + appendNameValue(buf, "Protocol-Capabilities", "timeline,playback,navigation,playqueues") + appendNameValue(buf, "Version", settings.GetGlobal("appVersionStr")) + appendNameValue(buf, "Resource-Identifier", settings.GetGlobal("clientIdentifier")) + appendNameValue(buf, "Device-Class", "stb") + + m.responseString = buf + + Debug("Built GDM player response:" + m.responseString) + end if + + return m.responseString + + + sub appendNameValue(buf, name, value) + line = name + ": " + value + WinNL() + buf.AppendString(line, Len(line)) + +''' diff --git a/script.plexmod/lib/_included_packages/plexnet/http.py b/script.plexmod/lib/_included_packages/plexnet/http.py new file mode 100644 index 000000000..978dd849d --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/http.py @@ -0,0 +1,329 @@ +from __future__ import absolute_import +import sys +import os +import re +import traceback +import requests +import socket +import urllib3 +from . import threadutils +import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error +import mimetypes +from . import plexobjects +from xml.etree import ElementTree + +from . import asyncadapter + +from . import callback +from . import util + + +codes = requests.codes +status_codes = requests.status_codes._codes + + +DEFAULT_TIMEOUT = asyncadapter.AsyncTimeout(util.TIMEOUT).setConnectTimeout(util.TIMEOUT) + + +def GET(*args, **kwargs): + return requests.get(*args, headers=util.BASE_HEADERS.copy(), timeout=util.TIMEOUT, **kwargs) + + +def POST(*args, **kwargs): + return requests.post(*args, headers=util.BASE_HEADERS.copy(), timeout=util.TIMEOUT, **kwargs) + + +def Session(): + s = asyncadapter.Session() + s.headers = util.BASE_HEADERS.copy() + s.timeout = util.TIMEOUT + + return s + + +class RequestContext(dict): + def __getattr__(self, attr): + return self.get(attr) + + def __setattr__(self, attr, value): + self[attr] = value + + +class HttpRequest(object): + _cancel = False + + def __init__(self, url, method=None, forceCertificate=False): + self.server = None + self.path = None + self.hasParams = '?' in url + self.ignoreResponse = False + self.session = asyncadapter.Session() + self.session.headers = util.BASE_HEADERS.copy() + self.currentResponse = None + self.method = method + self.url = url + self.thread = None + + # Use our specific plex.direct CA cert if applicable to improve performance + # if forceCertificate or url[:5] == "https": # TODO: ---------------------------------------------------------------------------------IMPLEMENT + # certsPath = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'certs') + # if "plex.direct" in url: + # self.session.cert = os.path.join(certsPath, 'plex-bundle.crt') + # else: + # self.session.cert = os.path.join(certsPath, 'ca-bundle.crt') + + def removeAsPending(self): + from . import plexapp + util.APP.delRequest(self) + + def startAsync(self, *args, **kwargs): + self.thread = threadutils.KillableThread(target=self._startAsync, args=args, kwargs=kwargs, name='HTTP-ASYNC:{0}'.format(self.url)) + self.thread.start() + return True + + def _startAsync(self, body=None, contentType=None, context=None): + timeout = context and context.timeout or DEFAULT_TIMEOUT + self.logRequest(body, timeout) + if self._cancel: + return + try: + if self.method == 'PUT': + res = self.session.put(self.url, timeout=timeout, stream=True) + elif self.method == 'DELETE': + res = self.session.delete(self.url, timeout=timeout, stream=True) + elif self.method == 'HEAD': + res = self.session.head(self.url, timeout=timeout, stream=True) + elif self.method == 'POST' or body is not None: + if not contentType: + self.session.headers["Content-Type"] = "application/x-www-form-urlencoded" + else: + self.session.headers["Content-Type"] = mimetypes.guess_type(contentType) + + res = self.session.post(self.url, data=body or None, timeout=timeout, stream=True) + else: + res = self.session.get(self.url, timeout=timeout, stream=True) + self.currentResponse = res + + if self._cancel: + return + except asyncadapter.TimeoutException: + from . import plexapp + plexapp.util.APP.onRequestTimeout(context) + self.removeAsPending() + return + except asyncadapter.CanceledException: + return + except (urllib3.exceptions.ProtocolError, requests.exceptions.ConnectionError): + self.removeAsPending() + return + except Exception as e: + util.ERROR('Request failed {0}'.format(util.cleanToken(self.url))) + if not hasattr(e, 'response'): + return + res = e.response + + self.onResponse(res, context) + + self.removeAsPending() + + def getWithTimeout(self, seconds=DEFAULT_TIMEOUT): + return HttpObjectResponse(self.getPostWithTimeout(seconds), self.path, self.server) + + def postWithTimeout(self, seconds=DEFAULT_TIMEOUT, body=None): + self.method = 'POST' + return HttpObjectResponse(self.getPostWithTimeout(seconds, body), self.path, self.server) + + def getToStringWithTimeout(self, seconds=DEFAULT_TIMEOUT): + res = self.getPostWithTimeout(seconds) + if not res: + return '' + return res.text.encode('utf8') + + def postToStringWithTimeout(self, body=None, seconds=DEFAULT_TIMEOUT): + self.method = 'POST' + res = self.getPostWithTimeout(seconds, body) + if not res: + return '' + return res.text.encode('utf8') + + def getPostWithTimeout(self, seconds=DEFAULT_TIMEOUT, body=None): + if self._cancel: + return + + self.logRequest(body, seconds, False) + try: + if self.method == 'PUT': + res = self.session.put(self.url, timeout=seconds, stream=True) + elif self.method == 'DELETE': + res = self.session.delete(self.url, timeout=seconds, stream=True) + elif self.method == 'HEAD': + res = self.session.head(self.url, timeout=seconds, stream=True) + elif self.method == 'POST' or body is not None: + res = self.session.post(self.url, data=body, timeout=seconds, stream=True) + else: + res = self.session.get(self.url, timeout=seconds, stream=True) + + self.currentResponse = res + + if self._cancel: + return None + + util.LOG("Got a {0} from {1}".format(res.status_code, util.cleanToken(self.url))) + # self.event = msg + return res + except Exception as e: + info = traceback.extract_tb(sys.exc_info()[2])[-1] + util.WARN_LOG( + "Request errored out - URL: {0} File: {1} Line: {2} Msg: {3}".format(util.cleanToken(self.url), os.path.basename(info[0]), info[1], getattr(e, 'message', '')) + ) + + return None + + def wasOK(self): + return self.currentResponse and self.currentResponse.ok + + def wasNotFound(self): + return self.currentResponse is not None and self.currentResponse.status_code == requests.codes.not_found + + def getIdentity(self): + return str(id(self)) + + def getUrl(self): + return self.url + + def getRelativeUrl(self): + url = self.getUrl() + m = re.match('^\w+:\/\/.+?(\/.+)', url) + if m: + return m.group(1) + return url + + def killSocket(self): + if not self.currentResponse: + return + + try: + socket.fromfd(self.currentResponse.raw.fileno(), socket.AF_INET, socket.SOCK_STREAM).shutdown(socket.SHUT_RDWR) + return + except AttributeError: + pass + except Exception as e: + util.ERROR(err=e) + + try: + self.currentResponse.raw._fp.fp._sock.shutdown(socket.SHUT_RDWR) + except AttributeError: + pass + except Exception as e: + util.ERROR(err=e) + + def cancel(self): + self._cancel = True + self.session.cancel() + self.removeAsPending() + self.killSocket() + + def addParam(self, encodedName, value): + if self.hasParams: + self.url += "&" + encodedName + "=" + six.moves.urllib.parse.quote_plus(value) + else: + self.hasParams = True + self.url += "?" + encodedName + "=" + six.moves.urllib.parse.quote_plus(value) + + def addHeader(self, name, value): + self.session.headers[name] = value + + def createRequestContext(self, requestType, callback_=None, timeout=None): + context = RequestContext() + context.requestType = requestType + context.timeout = timeout or DEFAULT_TIMEOUT + + if callback_: + context.callback = callback.Callable(self.onResponse) + context.completionCallback = callback_ + context.callbackCtx = callback_.context + + return context + + def onResponse(self, event, context): + if context.completionCallback: + response = HttpResponse(event) + context.completionCallback(self, response, context) + + def logRequest(self, body, timeout=None, _async=True): + # Log the real request method + method = self.method + if not method: + method = body is not None and "POST" or "GET" + util.LOG( + "Starting request: {0} {1} (async={2} timeout={3})".format(method, util.cleanToken(self.url), + _async, timeout) + ) + + +class HttpResponse(object): + def __init__(self, event): + self.event = event + if not self.event is None: + self.event.content # force data to be read + self.event.close() + + def isSuccess(self): + if not self.event: + return False + return self.event.status_code >= 200 and self.event.status_code < 300 + + def isError(self): + return not self.isSuccess() + + def getStatus(self): + if self.event is None: + return 0 + return self.event.status_code + + def getBodyString(self): + if self.event is None: + return '' + return self.event.text.encode('utf-8') + + def getErrorString(self): + if self.event is None: + return '' + return self.event.reason + + def getBodyXml(self): + if not self.event is None: + return ElementTree.fromstring(self.getBodyString()) + + return None + + def getResponseHeader(self, name): + if self.event is None: + return None + return self.event.headers.get(name) + + +class HttpObjectResponse(HttpResponse, plexobjects.PlexContainer): + def __init__(self, response, path, server=None): + self.event = response + if self.event: + self.event.content # force data to be read + self.event.close() + + data = self.getBodyXml() + + plexobjects.PlexContainer.__init__(self, data, initpath=path, server=server, address=path) + self.container = self + + self.items = plexobjects.listItems(server, path, data=data, container=self) + + +def addRequestHeaders(transferObj, headers=None): + if isinstance(headers, dict): + for header in headers: + transferObj.addHeader(header, headers[header]) + util.DEBUG_LOG("Adding header to {0}: {1}: {2}".format(transferObj, header, headers[header])) + + +def addUrlParam(url, param): + return url + ('?' in url and '&' or '?') + param diff --git a/script.plexmod/lib/_included_packages/plexnet/locks.py b/script.plexmod/lib/_included_packages/plexnet/locks.py new file mode 100644 index 000000000..5e309f4ee --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/locks.py @@ -0,0 +1,67 @@ +# Generic Locks. These are only virtual. You will need to check for the lock to +# ignore processing depending on the lockName. +# * Locks().Lock("lockName") : creates virtual lock +# * Locks().IsLocked("lockName") : returns true if locked +# * Locks().Unlock("lockName") : return true if existed & removed +from __future__ import absolute_import +from . import util + + +class Locks(object): + def __init__(self): + self.locks = {} + self.oneTimeLocks = {} + + def lock(self, name): + self.locks[name] = (self.locks.get(name) or 0) + 1 + util.DEBUG_LOG("Lock {0}, total={0}".format(name, self.locks[name])) + + def lockOnce(self, name): + util.DEBUG_LOG("Locking once {0}".format(name)) + self.oneTimeLocks[name] = True + + def unlock(self, name, forceUnlock=False): + oneTime = False + if name in self.oneTimeLocks: + del self.oneTimeLocks[name] + oneTime = True + normal = (self.locks.get(name) or 0) > 0 + + if normal: + if forceUnlock: + self.locks[name] = 0 + else: + self.locks[name] -= 1 + + if self.locks[name] <= 0: + del self.locks[name] + else: + normal = False + + unlocked = (normal or oneTime) + util.DEBUG_LOG("Unlock {0}, total={1}, unlocked={2}".format(name, self.locks.get(name) or 0, unlocked)) + + return unlocked + + def isLocked(self, name): + return name in self.oneTimeLocks or name in self.locks + # return (self.oneTimeLocks.Delete(name) or self.locks.DoesExist(name)) + + +# lock helpers +def disableBackButton(): + LOCKS.lock("BackButton") + + +def enableBackButton(): + LOCKS.unlock("BackButton", True) + + +def disableRemoteControl(): + LOCKS.lock("roUniversalControlEvent") + + +def enableRemoteControl(): + LOCKS.unlock("roUniversalControlEvent", True) + +LOCKS = Locks() diff --git a/script.plexmod/lib/_included_packages/plexnet/media.py b/script.plexmod/lib/_included_packages/plexnet/media.py new file mode 100644 index 000000000..56d70eb02 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/media.py @@ -0,0 +1,258 @@ +from __future__ import absolute_import +from . import plexobjects +from . import plexstream +from . import util + +METADATA_RELATED_TRAILER = 1 +METADATA_RELATED_DELETED_SCENE = 2 +METADATA_RELATED_INTERVIEW = 3 +METADATA_RELATED_MUSIC_VIDEO = 4 +METADATA_RELATED_BEHIND_THE_SCENES = 5 +METADATA_RELATED_SCENE_OR_SAMPLE = 6 +METADATA_RELATED_LIVE_MUSIC_VIDEO = 7 +METADATA_RELATED_LYRIC_MUSIC_VIDEO = 8 +METADATA_RELATED_CONCERT = 9 +METADATA_RELATED_FEATURETTE = 10 +METADATA_RELATED_SHORT = 11 +METADATA_RELATED_OTHER = 12 + + +class MediaItem(plexobjects.PlexObject): + def getIdentifier(self): + identifier = self.get('identifier') or None + + if identifier is None: + identifier = self.container.identifier + + # HACK + # PMS doesn't return an identifier for playlist items. If we haven't found + # an identifier and the key looks like a library item, then we pretend like + # the identifier was set. + # + if identifier is None: # Modified from Roku code which had no check for None with iPhoto - is that right? + if self.key.startswith('/library/metadata'): + identifier = "com.plexapp.plugins.library" + elif self.isIPhoto(): + identifier = "com.plexapp.plugins.iphoto" + + return identifier + + def getQualityType(self, server=None): + if self.isOnlineItem(): + return util.QUALITY_ONLINE + + if not server: + server = self.getServer() + + return util.QUALITY_LOCAL if server.isLocalConnection() else util.QUALITY_REMOTE + + def delete(self): + if not self.ratingKey: + return + + from . import plexrequest + req = plexrequest.PlexRequest(self.server, '/library/metadata/{0}'.format(self.ratingKey), method='DELETE') + req.getToStringWithTimeout(10) + self.deleted = req.wasOK() + return self.deleted + + def exists(self): + if self.deleted or self.deletedAt: + return False + + data = self.server.query('/library/metadata/{0}'.format(self.ratingKey)) + return data is not None and data.attrib.get('size') != '0' + # req = plexrequest.PlexRequest(self.server, '/library/metadata/{0}'.format(self.ratingKey), method='HEAD') + # req.getToStringWithTimeout(10) + # return not req.wasNotFound() + + def fixedDuration(self): + duration = self.duration.asInt() + if duration < 1000: + duration *= 60000 + return duration + + +class Media(plexobjects.PlexObject): + TYPE = 'Media' + + def __init__(self, data, initpath=None, server=None, video=None): + plexobjects.PlexObject.__init__(self, data, initpath=initpath, server=server) + self.video = video + self.parts = [MediaPart(elem, initpath=self.initpath, server=self.server, media=self) for elem in data] + + def __repr__(self): + title = self.video.title.replace(' ', '.')[0:20] + return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8')) + + +class MediaPart(plexobjects.PlexObject): + TYPE = 'Part' + + def __init__(self, data, initpath=None, server=None, media=None): + plexobjects.PlexObject.__init__(self, data, initpath=initpath, server=server) + self.media = media + self.streams = [MediaPartStream.parse(e, initpath=self.initpath, server=server, part=self) for e in data if e.tag == 'Stream'] + + def __repr__(self): + return '<%s:%s>' % (self.__class__.__name__, self.id) + + def selectedStream(self, stream_type): + streams = [x for x in self.streams if stream_type == x.type] + selected = list([x for x in streams if x.selected is True]) + if len(selected) == 0: + return None + return selected[0] + + +class MediaPartStream(plexstream.PlexStream): + TYPE = None + STREAMTYPE = None + + def __init__(self, data, initpath=None, server=None, part=None): + plexobjects.PlexObject.__init__(self, data, initpath, server) + self.part = part + + @staticmethod + def parse(data, initpath=None, server=None, part=None): + STREAMCLS = { + 1: VideoStream, + 2: AudioStream, + 3: SubtitleStream + } + stype = int(data.attrib.get('streamType')) + cls = STREAMCLS.get(stype, MediaPartStream) + return cls(data, initpath=initpath, server=server, part=part) + + def __repr__(self): + return '<%s:%s>' % (self.__class__.__name__, self.id) + + +class VideoStream(MediaPartStream): + TYPE = 'videostream' + STREAMTYPE = plexstream.PlexStream.TYPE_VIDEO + + +class AudioStream(MediaPartStream): + TYPE = 'audiostream' + STREAMTYPE = plexstream.PlexStream.TYPE_AUDIO + + +class SubtitleStream(MediaPartStream): + TYPE = 'subtitlestream' + STREAMTYPE = plexstream.PlexStream.TYPE_SUBTITLE + + +class TranscodeSession(plexobjects.PlexObject): + TYPE = 'TranscodeSession' + + +class MediaTag(plexobjects.PlexObject): + TYPE = None + ID = 'None' + + def __repr__(self): + tag = self.tag.replace(' ', '.')[0:20] + return '<%s:%s:%s>' % (self.__class__.__name__, self.id, tag) + + def __eq__(self, other): + if other.__class__ != self.__class__: + return False + + return self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) + + +class Collection(MediaTag): + TYPE = 'Collection' + FILTER = 'collection' + + +class Country(MediaTag): + TYPE = 'Country' + FILTER = 'country' + + +class Director(MediaTag): + TYPE = 'Director' + FILTER = 'director' + ID = '4' + + +class Genre(MediaTag): + TYPE = 'Genre' + FILTER = 'genre' + ID = '1' + + +class Mood(MediaTag): + TYPE = 'Mood' + FILTER = 'mood' + + +class Producer(MediaTag): + TYPE = 'Producer' + FILTER = 'producer' + + +class Role(MediaTag): + TYPE = 'Role' + FILTER = 'actor' + ID = '6' + + def sectionRoles(self): + hubs = self.server.hubs(count=10, search_query=self.tag) + for hub in hubs: + if hub.type == 'actor': + break + else: + return None + + roles = [] + + for actor in hub.items: + if actor.id == self.id: + roles.append(actor) + + return roles or None + + +class Similar(MediaTag): + TYPE = 'Similar' + FILTER = 'similar' + + +class Writer(MediaTag): + TYPE = 'Writer' + FILTER = 'writer' + + +class Chapter(MediaTag): + TYPE = 'Chapter' + + def startTime(self): + return self.get('startTimeOffset', -1).asInt() + + +class Bandwidth(plexobjects.PlexObject): + TYPE = 'Bandwidth' + + +class Marker(MediaTag): + TYPE = 'Marker' + FILTER = 'Marker' + + +class Review(MediaTag): + TYPE = 'Review' + FILTER = 'Review' + + def ratingImage(self): + # only rottentomatoes currently supported + img = str(self.image) + if not img or not img.startswith("rottentomatoes://"): + return '' + + return img.split('rottentomatoes://')[1] diff --git a/script.plexmod/lib/_included_packages/plexnet/mediachoice.py b/script.plexmod/lib/_included_packages/plexnet/mediachoice.py new file mode 100644 index 000000000..a41f5c7c8 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/mediachoice.py @@ -0,0 +1,55 @@ +from __future__ import absolute_import +from . import plexstream +from . import util + + +class MediaChoice(object): + SUBTITLES_DEFAULT = 0 + SUBTITLES_BURN = 1 + SUBTITLES_SOFT_DP = 2 + SUBTITLES_SOFT_ANY = 3 + + def __init__(self, media=None, partIndex=0): + self.media = media + self.part = None + self.forceTranscode = False + self.isDirectPlayable = False + self.videoStream = None + self.audioStream = None + self.subtitleStream = None + self.isSelected = False + self.subtitleDecision = self.SUBTITLES_DEFAULT + + self.sorts = util.AttributeDict() + + if media: + self.indirectHeaders = media.indirectHeaders + self.part = media.parts[partIndex] + if not self.part: + for part in media.parts: + if part.isAccessible(): + self.part = part + + if self.part: + # We generally just rely on PMS to have told us selected streams, so + # initialize our streams accordingly. + + self.videoStream = self.part.getSelectedStreamOfType(plexstream.PlexStream.TYPE_VIDEO) + self.audioStream = self.part.getSelectedStreamOfType(plexstream.PlexStream.TYPE_AUDIO) + self.subtitleStream = self.part.getSelectedStreamOfType(plexstream.PlexStream.TYPE_SUBTITLE) + else: + util.WARN_LOG("Media does not contain a valid part") + + util.LOG("Choice media: {0} part:{1}".format(media, partIndex)) + for streamType in ("videoStream", "audioStream", "subtitleStream"): + attr = getattr(self, streamType) + if attr: + util.LOG("Choice {0}: {1}".format(streamType, repr(attr))) + else: + util.WARN_LOG("Could not create media choice for invalid media") + + def __str__(self): + return "direct playable={0} version={1}".format(self.isDirectPlayable, self.media) + + def __repr__(self): + return self.__str__() diff --git a/script.plexmod/lib/_included_packages/plexnet/mediadecisionengine.py b/script.plexmod/lib/_included_packages/plexnet/mediadecisionengine.py new file mode 100644 index 000000000..ffda4ef5a --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/mediadecisionengine.py @@ -0,0 +1,510 @@ +from __future__ import absolute_import +from . import mediachoice +from . import serverdecision +from . import plexapp +from . import util +import six +from six.moves import range + + +class MediaDecisionEngine(object): + proxyTypes = util.AttributeDict({ + 'NORMAL': 0, + 'LOCAL': 42, + 'CLOUD': 43 + }) + + def __init__(self): + self.softSubLanguages = None + + # TODO(schuyler): Do we need to allow this to be async? We may have to request + # the media again to fetch details, and we may need to make multiple requests to + # resolve an indirect. We can do it all async, we can block, or we can allow + # both. + + def chooseMedia(self, item, forceUpdate=False): + # If we've already evaluated this item, use our previous choice. + if not forceUpdate and item.mediaChoice is not None and item.mediaChoice.media is not None and not item.mediaChoice.media.isIndirect(): + return item.mediaChoice + + # See if we're missing media/stream details for this item. + if item.isLibraryItem() and item.isVideoItem() and len(item.media) > 0 and not item.media[0].hasStreams(): + # TODO(schuyler): Fetch the details + util.WARN_LOG("Can't make media choice, missing details") + + # Take a first pass through the media items to create an array of candidates + # that we'll evaluate more completely. If we find a forced item, we use it. + # If we find an indirect, we only keep a single candidate. + indirect = False + candidates = [] + maxResolution = item.settings.getMaxResolution(item.getQualityType()) + for mediaIndex in range(len(item.media)): + media = item.media[mediaIndex] + media.mediaIndex = mediaIndex + if media.isSelected(): + candidates = [] + candidates.append(media) + break + if media.isIndirect(): + # Only add indirect media if the resolution fits. We cannot + # exit early as the user may have selected media. + + indirect = True + if media.getVideoResolution() <= maxResolution: + candidates.append(media) + + elif media.isAccessible(): + # Only consider testing available media + candidates.append(media) + + # Only use the first indirect media item + if indirect and candidates: + candidates = candidates[0] + + # Make sure we have at least one valid item, regardless of availability + if len(candidates) == 0: + candidates.append(item.media[0]) + + # Now that we have an array of candidates, evaluate them completely. + choices = [] + for media in candidates: + choice = None + if media is not None: + if item.isVideoItem(): + choice = self.evaluateMediaVideo(item, media) + elif item.isMusicItem(): + choice = self.evaluateMediaMusic(item, media) + else: + choice = mediachoice.MediaChoice(media) + choices.append(choice) + item.mediaChoice = self.sortChoices(choices)[-1] + util.LOG("MDE: MediaChoice: {0}".format(item.mediaChoice)) + return item.mediaChoice + + def sortChoices(self, choices): + if choices is None: + return [] + + if len(choices) > 1: + self.sort(choices, "bitrate") + self.sort(choices, "audioChannels") + self.sort(choices, "audioDS") + self.sort(choices, "resolution") + self.sort(choices, "videoDS") + self.sort(choices, "directPlay") + self.sort(choices, self.higherResIfCapable) + self.sort(choices, self.cloudIfRemote) + + return choices + + def evaluateMediaVideo(self, item, media, partIndex=0): + # Resolve indirects before doing anything else. + if media.isIndirect(): + util.LOG("Resolve indirect media for {0}".format(item)) + media = media.resolveIndirect() + + choice = mediachoice.MediaChoice(media, partIndex) + server = item.getServer() + + if not media: + return choice + + choice.isSelected = media.isSelected() + choice.protocol = media.protocol("http") + + maxResolution = item.settings.getMaxResolution(item.getQualityType(), self.isSupported4k(media, choice.videoStream)) + maxBitrate = item.settings.getMaxBitrate(item.getQualityType()) + + choice.resolution = media.getVideoResolution() + if choice.resolution > maxResolution or media.bitrate.asInt() > maxBitrate: + choice.forceTranscode = True + + if choice.subtitleStream: + choice.subtitleDecision = self.evaluateSubtitles(choice.subtitleStream) + choice.hasBurnedInSubtitles = (choice.subtitleDecision != choice.SUBTITLES_SOFT_DP and choice.subtitleDecision != choice.SUBTITLES_SOFT_ANY) + else: + choice.hasBurnedInSubtitles = False + + # For evaluation purposes, we only care about the first part + part = media.parts[partIndex] + if not part: + return choice + + # Although PMS has already told us which streams are selected, we can't + # necessarily tell the video player which streams we want. So we need to + # iterate over the streams and see if there are any red flags that would + # prevent direct play. If there are multiple video streams, we're hosed. + # For audio streams, we have a fighting chance if the selected stream can + # be selected by language, but we need to be careful about guessing which + # audio stream the Roku will pick for a given language. + + numVideoStreams = 0 + problematicAudioStream = False + + if part.get('hasChapterVideoStream').asBool(): + numVideoStreams = 1 + + for stream in part.streams: + streamType = stream.streamType.asInt() + if streamType == stream.TYPE_VIDEO: + numVideoStreams = numVideoStreams + 1 + + if stream.codec == "h264" or ( + stream.codec == "hevc" and item.settings.getPreference("allow_hevc", True) + ) or ( + stream.codec == "av1" and item.settings.getPreference("allow_av1", False) + ) or ( + stream.codec == "vp9" and item.settings.getGlobal("vp9Support") + ): + choice.sorts.videoDS = 1 + + # Special cases to force direct play + forceDirectPlay = False + if choice.protocol == "hls": + util.LOG("MDE: Assuming HLS is direct playable") + forceDirectPlay = True + elif not server.supportsVideoTranscoding: + # See if we can use another server to transcode, otherwise force direct play + transcodeServer = item.getTranscodeServer(True, "video") + if not transcodeServer or not transcodeServer.supportsVideoTranscoding: + util.LOG("MDE: force direct play because the server does not support video transcoding") + forceDirectPlay = True + + # See if we found any red flags based on the streams. Otherwise, go ahead + # with our codec checks. + + if forceDirectPlay: + # Consider the choice DP, but continue to allow the + # choice to have the sorts set properly. + choice.isDirectPlayable = True + elif choice.hasBurnedInSubtitles: + util.LOG("MDE: Need to burn in subtitles") + elif choice.protocol != "http": + util.LOG("MDE: " + choice.protocol + " not supported") + # elif numVideoStreams > 1: + # util.LOG("MDE: Multiple video streams, won't try to direct play") + elif problematicAudioStream: + util.LOG("MDE: Problematic AAC stream with more than 2 channels prevents direct play") + elif self.canDirectPlay(item, choice): + choice.isDirectPlayable = True + elif item.isMediaSynthesized: + util.LOG("MDE: assuming synthesized media can direct play") + choice.isDirectPlayable = True + + # Check for a server decision. This is authority as it's the only playback type + # the server will allow. This will also support forcing direct play, overriding + # only our local MDE checks based on the user pref, and only if the server + # agrees. + decision = part.get("decision") + if decision: + if decision != serverdecision.ServerDecision.DECISION_DIRECT_PLAY: + util.LOG("MDE: Server has decided this cannot direct play") + choice.isDirectPlayable = False + else: + util.LOG("MDE: Server has allowed direct play") + choice.isDirectPlayable = True + + # Setup sorts + if choice.videoStream is not None: + choice.sorts.bitrate = choice.videoStream.bitrate.asInt() + elif choice.media is not None: + choice.sorts.bitrate = choice.media.bitrate.asInt() + else: + choice.sorts.bitrate = 0 + + if choice.audioStream is not None: + choice.sorts.audioChannels = choice.audioStream.channels.asInt() + elif choice.media is not None: + choice.sorts.audioChannels = choice.media.audioChannels.asInt() + else: + choice.sorts.audioChannels = 0 + + AC3Cond = item.settings.getPreference("audio_force_ac3_cond", 'never') + + if AC3Cond != 'never': + allowed = ["ac3"] + if item.settings.getPreference("audio_ac3dts", False): + allowed.append("dca") + + if AC3Cond == 'always' and choice.audioStream.codec not in allowed: + util.LOG("MDE: Codec {} can't be direct played due to user settings".format(choice.audioStream.codec)) + choice.isDirectPlayable = False + + elif AC3Cond in ('2', '5'): + ch = int(AC3Cond) + ach = choice.sorts.audioChannels + + # got AC3/DTS but channels don't match + if choice.audioStream.codec in allowed and ach > ch: + util.LOG("MDE: {}-channel AC3/DTS can't be direct played due " + "to user settings ({} ch)".format(ach, ch)) + choice.isDirectPlayable = False + # other codec and channels don't match + elif ach > ch: + util.LOG("MDE: {}-channel {} can't be direct played due to " + "user settings ({} ch)".format(ach, choice.audioStream.codec, ch)) + choice.isDirectPlayable = False + + choice.sorts.videoDS = not ( + choice.sorts.videoDS is None or choice.forceTranscode is True) and choice.sorts.videoDS or 0 + choice.sorts.resolution = choice.resolution + + # Server properties probably don't need to be associated with each choice + choice.sorts.canTranscode = server.supportsVideoTranscoding and 1 or 0 + choice.sorts.canRemuxOnly = server.supportsVideoRemuxOnly and 1 or 0 + choice.sorts.directPlay = (choice.isDirectPlayable is True and choice.forceTranscode is not True) and 1 or 0 + choice.sorts.proxyType = choice.media.proxyType and choice.media.proxyType or self.proxyTypes.NORMAL + + return choice + + def canDirectPlay(self, item, choice): + maxResolution = item.settings.getMaxResolution(item.getQualityType(), self.isSupported4k(choice.media, choice.videoStream)) + height = choice.media.getVideoResolution() + if height > maxResolution: + util.LOG("MDE: (DP) Video height is greater than max allowed: {0} > {1}".format(height, maxResolution)) + if height > 1088 and item.settings.getPreference("allow_4k", True): + util.LOG("MDE: (DP) Unsupported 4k media") + return False + + maxBitrate = item.settings.getMaxBitrate(item.getQualityType()) + bitrate = choice.media.bitrate.asInt() + if bitrate > maxBitrate: + util.LOG("MDE: (DP) Video bitrate is greater than the allowed max: {0} > {1}".format(bitrate, maxBitrate)) + return False + + if choice.videoStream is None: + util.ERROR_LOG("MDE: (DP) No video stream") + return True + + if not item.settings.getGlobal("supports1080p60"): + videoFrameRate = choice.videoStream.asInt() + if videoFrameRate > 30 and height >= 1080: + util.LOG("MDE: (DP) Frame rate is not supported for resolution: {0}@{1}".format(height, videoFrameRate)) + return False + + if choice.videoStream.codec == "hevc" and not item.settings.getPreference("allow_hevc", True): + util.LOG("MDE: (DP) Codec is HEVC, which is disabled") + return False + + if choice.videoStream.codec == "av1" and not item.settings.getPreference("allow_av1", False): + util.LOG("MDE: (DP) Codec is AV1, which is disabled") + return False + + return True + + # container = choice.media.get('container') + # videoCodec = choice.videoStream.codec + # if choice.audioStream is None: + # audioCodec = None + # numChannels = 0 + # else: + # audioCodec = choice.audioStream.codec + # numChannels = choice.audioStream.channels.asInt() + + # Formats: https://support.roku.com/hc/en-us/articles/208754908-Roku-Media-Player-Playing-your-personal-videos-music-photos + # All Models: H.264/AVC (MKV, MP4, MOV), + # Roku 4 only: H.265/HEVC (MKV, MP4, MOV); VP9 (.MKV) + + # if True: # container in ("mp4", "mov", "m4v", "mkv"): + # util.LOG("MDE: {0} container looks OK, checking streams".format(container)) + + # isHEVC = videoCodec == "hevc" and item.settings.getPreference("allow_hevc", False) + # isVP9 = videoCodec == "vp9" and container == "mkv" and item.settings.getGlobal("vp9Support") + + # if videoCodec != "h264" and videoCodec != "mpeg4" and not isHEVC and not isVP9: + # util.LOG("MDE: Unsupported video codec: {0}".format(videoCodec)) + # return False + + # # TODO(schuyler): Fix ref frames check. It's more nuanced than this. + # if choice.videoStream.refFrames.asInt() > 8: + # util.LOG("MDE: Too many ref frames: {0}".format(choice.videoStream.refFrames)) + # return False + + # # HEVC supports a bitDepth of 10, otherwise 8 is the limit + # if choice.videoStream.bitDepth.asInt() > (isHEVC and 10 or 8): + # util.LOG("MDE: Bit depth too high: {0}".format(choice.videoStream.bitDepth)) + # return False + + # # We shouldn't have to whitelist particular audio codecs, we can just + # # check to see if the Roku can decode this codec with the number of channels. + # if not item.settings.supportsAudioStream(audioCodec, numChannels): + # util.LOG("MDE: Unsupported audio track: {0} ({1} channels)".format(audioCodec, numChannels)) + # return False + + # # # TODO(schuyler): We've reported this to Roku, they may fix it. If/when + # # # they do, we should move this behind a firmware version check. + # # if container == "mkv" and choice.videoStream.headerStripping.asBool() and audioCodec == "ac3": + # # util.ERROR_LOG("MDE: Header stripping with AC3 audio") + # # return False + + # # Those were our problems, everything else should be OK. + # return True + # else: + # util.LOG("MDE: Unsupported container: {0}".format(container)) + + # return False + + def evaluateSubtitles(self, stream): + if util.INTERFACE.getPreference("burn_subtitles") == "always": + # If the user prefers them burned, always burn + return mediachoice.MediaChoice.SUBTITLES_BURN + # elif stream.codec != "srt": + # # We only support soft subtitles for SRT. Anything else has to use the + # # transcoder, and we defer to it on whether the subs will have to be + # # burned or can be converted to SRT and muxed. + + # return mediachoice.MediaChoice.SUBTITLES_DEFAULT + elif stream.key is None: + # Embedded subs don't have keys and can only be direct played + result = mediachoice.MediaChoice.SUBTITLES_SOFT_DP + else: + # Sidecar subs can be direct played or used alongside a transcode + result = mediachoice.MediaChoice.SUBTITLES_SOFT_ANY + + # # TODO(schuyler) If Roku adds support for non-Latin characters, remove + # # this hackery. To the extent that we continue using this hackery, it + # # seems that the Roku requires UTF-8 subtitles but only supports characters + # # from Windows-1252. This should be the full set of languages that are + # # completely representable in Windows-1252. PMS should specifically be + # # returning ISO 639-2/B language codes. + # # Update: Roku has added support for additional characters, but still only + # # Latin characters. We can now basically support anything from the various + # # ISO-8859 character sets, but nothing non-Latin. + + # if not self.softSubLanguages: + # self.softSubLanguages = frozenset(( + # 'afr', + # 'alb', + # 'baq', + # 'bre', + # 'cat', + # 'cze', + # 'dan', + # 'dut', + # 'eng', + # 'epo', + # 'est', + # 'fao', + # 'fin', + # 'fre', + # 'ger', + # 'gla', + # 'gle', + # 'glg', + # 'hrv', + # 'hun', + # 'ice', + # 'ita', + # 'lat', + # 'lav', + # 'lit', + # 'ltz', + # 'may', + # 'mlt', + # 'nno', + # 'nob', + # 'nor', + # 'oci', + # 'pol', + # 'por', + # 'roh', + # 'rum', + # 'slo', + # 'slv', + # 'spa', + # 'srd', + # 'swa', + # 'swe', + # 'tur', + # 'vie', + # 'wel', + # 'wln' + # )) + + # if not (stream.languageCode or 'eng') in self.softSubLanguages: + # # If the language is unsupported,: we need to force burning + # result = mediachoice.MediaChoice.SUBTITLES_BURN + + return result + + def evaluateMediaMusic(self, item, media): + # Resolve indirects before doing anything else. + if media.isIndirect(): + util.LOG("Resolve indirect media for {0}".format(item)) + media = media.resolveIndirect() + + choice = mediachoice.MediaChoice(media) + if media is None: + return choice + + # Verify the server supports audio transcoding, otherwise force direct play + if not item.getServer().supportsAudioTranscoding: + util.LOG("MDE: force direct play because the server does not support audio transcoding") + choice.isDirectPlayable = True + return choice + + # See if this part has a server decision to transcode and obey it + if choice.part and choice.part.get( + "decision", serverdecision.ServerDecision.DECISION_DIRECT_PLAY + ) != serverdecision.ServerDecision.DECISION_DIRECT_PLAY: + util.WARN_LOG("MDE: Server has decided this cannot direct play") + return choice + + # Verify the codec and container are compatible + codec = media.audioCodec + container = media.get('container') + canPlayCodec = item.settings.supportsAudioStream(codec, media.audioChannels.asInt()) + canPlayContainer = (codec == container) or True # (container in ("mp4", "mka", "mkv")) + + choice.isDirectPlayable = (canPlayCodec and canPlayContainer) + if choice.isDirectPlayable: + # Inspect the audio stream attributes if the codec/container can direct + # play. For now we only need to verify the sample rate. + + if choice.audioStream is not None and choice.audioStream.samplingRate.asInt() >= 192000: + util.LOG("MDE: sampling rate is not compatible") + choice.isDirectPlayable = False + else: + util.LOG("MDE: container or codec is incompatible") + + return choice + + # Simple Quick sort function modeled after roku sdk function + def sort(self, choices, key=None): + if not isinstance(choices, list): + return + + if key is None: + choices.sort() + elif isinstance(key, six.string_types): + choices.sort(key=lambda x: getattr(x.media, key)) + elif hasattr(key, '__call__'): + choices.sort(key=key) + + def higherResIfCapable(self, choice): + if choice.media is not None: + server = choice.media.getServer() + if server.supportsVideoTranscoding and not server.supportsVideoRemuxOnly and (choice.sorts.directPlay == 1 or choice.sorts.videoDS == 1): + return util.validInt(choice.sorts.resolution) + + return 0 + + def cloudIfRemote(self, choice): + if choice.media is not None and choice.media.getServer().isLocalConnection() and choice.media.proxyType != self.proxyTypes.CLOUD: + return 1 + + return 0 + + def isSupported4k(self, media, videoStream): + if videoStream is None or not util.INTERFACE.getPreference("allow_4k", True): + return False + + # # Roku 4 only: H.265/HEVC (MKV, MP4, MOV); VP9 (.MKV) + # if media.get('container') in ("mp4", "mov", "m4v", "mkv"): + # isHEVC = (videoStream.codec == "hevc" and util.INTERFACE.getPreference("allow_hevc")) + # isVP9 = (videoStream.codec == "vp9" and media.get('container') == "mkv" and util.INTERFACE.getGlobal("vp9Support")) + # return (isHEVC or isVP9) + + # return False + + return True diff --git a/script.plexmod/lib/_included_packages/plexnet/myplex.py b/script.plexmod/lib/_included_packages/plexnet/myplex.py new file mode 100644 index 000000000..94b88e962 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/myplex.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +from . import util +from . import http +from threading import Thread +from xml.etree import ElementTree +import time + +from . import exceptions + +from . import video +from . import audio +from . import photo +from . import plexobjects + +video, audio, photo # Hides warning message + + +class PinLogin(object): + INIT = 'https://plex.tv/pins.xml' + POLL = 'https://plex.tv/pins/{0}.xml' + POLL_INTERVAL = 1 + + def __init__(self, callback=None): + self._callback = callback + self.id = None + self.pin = None + self.authenticationToken = None + self._finished = False + self._abort = False + self._expired = False + self._init() + + def _init(self): + response = http.POST(self.INIT) + if response.status_code != http.codes.created: + codename = http.status_codes.get(response.status_code)[0] + raise exceptions.BadRequest('({0}) {1}'.format(response.status_code, codename)) + data = ElementTree.fromstring(response.text.encode('utf-8')) + self.pin = data.find('code').text + self.id = data.find('id').text + + def _poll(self): + try: + start = time.time() + while not self._abort and time.time() - start < 300: + try: + response = http.GET(self.POLL.format(self.id)) + except Exception as e: + util.ERROR('PinLogin connection error: {0}'.format(e.__class__), err=e) + time.sleep(self.POLL_INTERVAL) + continue + + if response.status_code != http.codes.ok: + self._expired = True + break + try: + data = ElementTree.fromstring(response.text.encode('utf-8')) + except Exception as e: + util.ERROR('PinLogin data error: {0}'.format(e.__class__), err=e) + time.sleep(self.POLL_INTERVAL) + continue + + token = data.find('auth_token').text + if token: + self.authenticationToken = token + break + time.sleep(self.POLL_INTERVAL) + + if self._callback: + self._callback(self.authenticationToken) + finally: + self._finished = True + + def finished(self): + return self._finished + + def expired(self): + return self._expired + + def startTokenPolling(self): + t = Thread(target=self._poll, name='PIN-LOGIN:Token-Poll') + t.start() + return t + + def waitForToken(self): + t = self.startTokenPolling() + t.join() + return self.authenticationToken + + def abort(self): + self._abort = True diff --git a/script.plexmod/lib/_included_packages/plexnet/myplexaccount.py b/script.plexmod/lib/_included_packages/plexnet/myplexaccount.py new file mode 100644 index 000000000..6eb1d65bb --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/myplexaccount.py @@ -0,0 +1,323 @@ +from __future__ import absolute_import +import json +import time +import hashlib +from xml.etree import ElementTree + +from . import plexapp +from . import myplexrequest +from . import locks +from . import callback +from . import asyncadapter + +from . import util + +ACCOUNT = None + + +class HomeUser(util.AttributeDict): + pass + + +class MyPlexAccount(object): + def __init__(self): + # Strings + self.ID = None + self.title = None + self.username = None + self.thumb = None + self.email = None + self.authToken = None + self.pin = None + self.thumb = None + + # Booleans + self.isAuthenticated = util.INTERFACE.getPreference('auto_signin', False) + self.isSignedIn = False + self.isOffline = False + self.isExpired = False + self.isPlexPass = False + self.isManaged = False + self.isSecure = False + self.hasQueue = False + + self.isAdmin = False + self.switchUser = False + + self.adminHasPlexPass = False + + self.lastHomeUserUpdate = None + self.homeUsers = [] + + def init(self): + self.loadState() + + def saveState(self): + obj = { + 'ID': self.ID, + 'title': self.title, + 'username': self.username, + 'email': self.email, + 'authToken': self.authToken, + 'pin': self.pin, + 'isPlexPass': self.isPlexPass, + 'isManaged': self.isManaged, + 'isAdmin': self.isAdmin, + 'isSecure': self.isSecure, + 'adminHasPlexPass': self.adminHasPlexPass, + 'thumb': self.thumb + } + + util.INTERFACE.setRegistry("MyPlexAccount", json.dumps(obj), "myplex") + + def loadState(self): + # Look for the new JSON serialization. If it's not there, look for the + # old token and Plex Pass values. + + util.APP.addInitializer("myplex") + + jstring = util.INTERFACE.getRegistry("MyPlexAccount", None, "myplex") + + if jstring: + try: + obj = json.loads(jstring) + except: + util.ERROR() + obj = None + + if obj: + self.ID = obj.get('ID') or self.ID + self.title = obj.get('title') or self.title + self.username = obj.get('username') or self.username + self.email = obj.get('email') or self.email + self.authToken = obj.get('authToken') or self.authToken + self.pin = obj.get('pin') or self.pin + self.isPlexPass = obj.get('isPlexPass') or self.isPlexPass + self.isManaged = obj.get('isManaged') or self.isManaged + self.isAdmin = obj.get('isAdmin') or self.isAdmin + self.isSecure = obj.get('isSecure') or self.isSecure + self.isProtected = bool(obj.get('pin')) + self.adminHasPlexPass = obj.get('adminHasPlexPass') or self.adminHasPlexPass + self.thumb = obj.get('thumb') + + def verifyAccount(self): + if self.authToken: + request = myplexrequest.MyPlexRequest("/users/account") + context = request.createRequestContext("account", callback.Callable(self.onAccountResponse), + timeout=util.LONG_TIMEOUT) + util.APP.startRequest(request, context) + else: + util.APP.clearInitializer("myplex") + + def logState(self): + util.LOG("Authenticated as {0}:{1}".format(self.ID, repr(self.title))) + util.LOG("SignedIn: {0}".format(self.isSignedIn)) + util.LOG("Offline: {0}".format(self.isOffline)) + util.LOG("Authenticated: {0}".format(self.isAuthenticated)) + util.LOG("PlexPass: {0}".format(self.isPlexPass)) + util.LOG("Managed: {0}".format(self.isManaged)) + util.LOG("Protected: {0}".format(self.isProtected)) + util.LOG("Admin: {0}".format(self.isAdmin)) + util.LOG("AdminPlexPass: {0}".format(self.adminHasPlexPass)) + + def onAccountResponse(self, request, response, context): + oldId = self.ID + + if response.isSuccess(): + data = response.getBodyXml() + + # The user is signed in + self.isSignedIn = True + self.isOffline = False + self.ID = data.attrib.get('id') + self.title = data.attrib.get('title') + self.username = data.attrib.get('username') + self.email = data.attrib.get('email') + self.thumb = data.attrib.get('thumb') + self.authToken = data.attrib.get('authenticationToken') + self.isPlexPass = (data.find('subscription') is not None and data.find('subscription').attrib.get('active') == '1') + self.isManaged = data.attrib.get('restricted') == '1' + self.isSecure = data.attrib.get('secure') == '1' + self.hasQueue = bool(data.attrib.get('queueEmail')) + + # PIN + if data.attrib.get('pin'): + self.pin = data.attrib.get('pin') + else: + self.pin = None + self.isProtected = bool(self.pin) + + # update the list of users in the home + self.updateHomeUsers() + + # set admin attribute for the user + self.isAdmin = False + if self.homeUsers: + for user in self.homeUsers: + if self.ID == user.id: + self.isAdmin = str(user.admin) == "1" + break + + if self.isAdmin and self.isPlexPass: + self.adminHasPlexPass = True + + # consider a single, unprotected user authenticated + if not self.isAuthenticated and not self.isProtected and len(self.homeUsers) <= 1: + self.isAuthenticated = True + + self.logState() + + self.saveState() + util.MANAGER.publish() + + if oldId != self.ID or self.switchUser: + util.DEBUG_LOG("User changed, deferring refresh resources (force=False)") + else: + util.DEBUG_LOG("User selected, refreshing resources (force=False)") + plexapp.refreshResources() + + elif response.getStatus() >= 400 and response.getStatus() < 500: + # The user is specifically unauthorized, clear everything + util.WARN_LOG("Sign Out: User is unauthorized") + self.signOut(True) + else: + # Unexpected error, keep using whatever we read from the registry + util.WARN_LOG("Unexpected response from plex.tv ({0}), switching to OFFLINE mode".format(response.getStatus())) + self.logState() + self.isOffline = True + # consider a single, unprotected user authenticated + if not self.isAuthenticated and not self.isProtected: + self.isAuthenticated = True + + util.APP.clearInitializer("myplex") + # Logger().UpdateSyslogHeader() # TODO: ------------------------------------------------------------------------------------------------------IMPLEMENT + + if oldId != self.ID or self.switchUser: + self.switchUser = None + util.APP.trigger("change:user", account=self, reallyChanged=oldId != self.ID) + + util.APP.trigger("account:response") + + def signOut(self, expired=False): + # Strings + self.ID = None + self.title = None + self.username = None + self.email = None + self.authToken = None + self.pin = None + self.lastHomeUserUpdate = None + + # Booleans + self.isSignedIn = False + self.isPlexPass = False + self.adminHasPlexPass = False + self.isManaged = False + self.isSecure = False + self.isExpired = expired + + # Clear the saved resources + util.INTERFACE.clearRegistry("mpaResources", "xml_cache") + + # Remove all saved servers + plexapp.SERVERMANAGER.clearServers() + + # Enable the welcome screen again + util.INTERFACE.setPreference("show_welcome", True) + + util.APP.trigger("change:user", account=self, reallyChanged=True) + + self.saveState() + + def hasPlexPass(self): + return self.isPlexPass or self.adminHasPlexPass + + def validateToken(self, token, switchUser=False): + self.authToken = token + self.switchUser = switchUser + + request = myplexrequest.MyPlexRequest("/users/sign_in.xml") + context = request.createRequestContext("sign_in", callback.Callable(self.onAccountResponse), + timeout=util.LONG_TIMEOUT) + if self.isOffline: + context.timeout = self.isOffline and asyncadapter.AsyncTimeout(1).setConnectTimeout(1) + util.APP.startRequest(request, context, {}) + + def refreshAccount(self): + if not self.authToken: + return + self.validateToken(self.authToken, False) + + def updateHomeUsers(self): + # Ignore request and clear any home users we are not signed in + if not self.isSignedIn: + self.homeUsers = [] + if self.isOffline: + self.homeUsers.append(MyPlexAccount()) + + self.lastHomeUserUpdate = None + return + + # Cache home users for 60 seconds, mainly to stop back to back tests + epoch = time.time() + if not self.lastHomeUserUpdate: + self.lastHomeUserUpdate = epoch + elif self.lastHomeUserUpdate + 60 > epoch: + util.DEBUG_LOG("Skipping home user update (updated {0} seconds ago)".format(epoch - self.lastHomeUserUpdate)) + return + + req = myplexrequest.MyPlexRequest("/api/home/users") + xml = req.getToStringWithTimeout(seconds=util.LONG_TIMEOUT) + data = ElementTree.fromstring(xml) + if data.attrib.get('size') and data.find('User') is not None: + self.homeUsers = [] + for user in data.findall('User'): + homeUser = HomeUser(user.attrib) + homeUser.isAdmin = homeUser.admin == "1" + homeUser.isManaged = homeUser.restricted == "1" + homeUser.isProtected = homeUser.protected == "1" + self.homeUsers.append(homeUser) + + self.lastHomeUserUpdate = epoch + + util.LOG("home users: {0}".format(self.homeUsers)) + + def switchHomeUser(self, userId, pin=''): + if userId == self.ID and self.isAuthenticated: + return True + + # Offline support + if self.isOffline: + hashed = 'NONE' + if pin and self.authToken: + hashed = hashlib.sha256(pin + self.authToken).digest() + + if not self.isProtected or self.isAuthenticated or hashed == (self.pin or ""): + util.DEBUG_LOG("OFFLINE access granted") + self.isAuthenticated = True + self.validateToken(self.authToken, True) + return True + else: + # build path and post to myplex to swith the user + path = '/api/home/users/{0}/switch'.format(userId) + req = myplexrequest.MyPlexRequest(path) + xml = req.postToStringWithTimeout({'pin': pin}, seconds=util.LONG_TIMEOUT) + try: + data = ElementTree.fromstring(xml) + except: + return False + + if data.attrib.get('authenticationToken'): + self.isAuthenticated = True + # validate the token (trigger change:user) on user change or channel startup + if userId != self.ID or not locks.LOCKS.isLocked("idleLock"): + self.validateToken(data.attrib.get('authenticationToken'), True) + return True + + return False + + def isActive(self): + return self.isSignedIn or self.isOffline + + +ACCOUNT = MyPlexAccount() diff --git a/script.plexmod/lib/_included_packages/plexnet/myplexmanager.py b/script.plexmod/lib/_included_packages/plexnet/myplexmanager.py new file mode 100644 index 000000000..72c2ebb9c --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/myplexmanager.py @@ -0,0 +1,79 @@ +from __future__ import absolute_import +from xml.etree import ElementTree +import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error + +from . import plexapp +from . import plexconnection +from . import plexserver +from . import myplexrequest +from . import callback +from . import util + + +class MyPlexManager(object): + def publish(self): + util.LOG('MyPlexManager().publish() - NOT IMPLEMENTED') + return # TODO: ----------------------------------------------------------------------------------------------------------------------------- IMPLEMENT? + request = myplexrequest.MyPlexRequest("/devices/" + util.INTERFACE.getGlobal("clientIdentifier")) + context = request.createRequestContext("publish") + + addrs = util.INTERFACE.getGlobal("roDeviceInfo").getIPAddrs() + + for iface in addrs: + request.addParam(six.moves.urllib.parse.quote("Connection[][uri]"), "http://{0):8324".format(addrs[iface])) + + util.APP.startRequest(request, context, "_method=PUT") + + def refreshResources(self, force=False): + util.LOG('MyPlexManager().refreshResources() - Force: {}'.format(force)) + if force: + plexapp.SERVERMANAGER.resetLastTest() + + request = myplexrequest.MyPlexRequest("/pms/resources") + context = request.createRequestContext("resources", callback.Callable(self.onResourcesResponse), + timeout=util.LONG_TIMEOUT) + + if plexapp.ACCOUNT.isSecure: + request.addParam("includeHttps", "1") + + util.APP.startRequest(request, context) + + def onResourcesResponse(self, request, response, context): + servers = [] + + response.parseResponse() + + # Save the last successful response to cache + if response.isSuccess() and response.event: + util.INTERFACE.setRegistry("mpaResources", response.event.text.encode('utf-8'), "xml_cache") + util.DEBUG_LOG("Saved resources response to registry") + # Load the last successful response from cache + elif util.INTERFACE.getRegistry("mpaResources", None, "xml_cache"): + data = ElementTree.fromstring(util.INTERFACE.getRegistry("mpaResources", None, "xml_cache")) + response.parseFakeXMLResponse(data) + util.DEBUG_LOG("Using cached resources") + + if response.container: + for resource in response.container: + util.DEBUG_LOG( + "Parsed resource from plex.tv: type:{0} clientIdentifier:{1} name:{2} product:{3} provides:{4}".format( + resource.type, + resource.clientIdentifier, + resource.name.encode('utf-8'), + resource.product.encode('utf-8'), + resource.provides.encode('utf-8') + ) + ) + + for conn in resource.connections: + util.DEBUG_LOG(' {0}'.format(conn)) + + if 'server' in resource.provides: + server = plexserver.createPlexServerForResource(resource) + util.DEBUG_LOG(' {0}'.format(server)) + servers.append(server) + + plexapp.SERVERMANAGER.updateFromConnectionType(servers, plexconnection.PlexConnection.SOURCE_MYPLEX) + + +MANAGER = MyPlexManager() diff --git a/script.plexmod/lib/_included_packages/plexnet/myplexrequest.py b/script.plexmod/lib/_included_packages/plexnet/myplexrequest.py new file mode 100644 index 000000000..5f560afe9 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/myplexrequest.py @@ -0,0 +1,13 @@ +# We don't particularly need a class definition here (yet?), it's just a +# PlexRequest where the server is fixed. +from __future__ import absolute_import +from . import plexrequest + + +class MyPlexRequest(plexrequest.PlexServerRequest): + def __init__(self, path): + from . import myplexserver + plexrequest.PlexServerRequest.__init__(self, myplexserver.MyPlexServer(), path) + + # Make sure we're always getting XML + self.addHeader("Accept", "application/xml") diff --git a/script.plexmod/lib/_included_packages/plexnet/myplexserver.py b/script.plexmod/lib/_included_packages/plexnet/myplexserver.py new file mode 100644 index 000000000..aad8e3f03 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/myplexserver.py @@ -0,0 +1,36 @@ +from __future__ import absolute_import +from . import plexapp +from . import plexconnection +from . import plexserver +from . import plexresource +from . import plexservermanager + + +class MyPlexServer(plexserver.PlexServer): + TYPE = 'MYPLEXSERVER' + + def __init__(self): + plexserver.PlexServer.__init__(self) + self.uuid = 'myplex' + self.name = 'plex.tv' + conn = plexconnection.PlexConnection(plexresource.ResourceConnection.SOURCE_MYPLEX, "https://plex.tv", False, None) + self.connections.append(conn) + self.activeConnection = conn + + def getToken(self): + return plexapp.ACCOUNT.authToken + + def buildUrl(self, path, includeToken=False): + if "://node.plexapp.com" in path: + # Locate the best fit server that supports channels, otherwise we'll + # continue to use the node urls. Service code between the node and + # PMS differs sometimes, so it's a toss up which one is actually + # more accurate. Either way, we try to offload work from the node. + + server = plexservermanager.MANAGER.getChannelServer() + if server: + url = server.swizzleUrl(path, includeToken) + if url: + return url + + return plexserver.PlexServer.buildUrl(self, path, includeToken) diff --git a/script.plexmod/lib/_included_packages/plexnet/netif/__init__.py b/script.plexmod/lib/_included_packages/plexnet/netif/__init__.py new file mode 100644 index 000000000..03c29ea03 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/netif/__init__.py @@ -0,0 +1,217 @@ +from __future__ import absolute_import +import socket +import struct +import os +from six.moves import range + + +class Interface: + def __init__(self): + self.name = '' + self.ip = '' + self.mask = '' + + @property + def broadcast(self): + if self.name == 'FALLBACK': return '' + if not self.ip or not self.mask: return None + return calcBroadcast(self.ip,self.mask) + + +def getInterfaces(): + if os.name == "nt": + try: + ret = _getInterfacesWin() + if ret: + return ret + except: + raise + + try: + return _getInterfacesWinPS() + except: + pass + + try: + return _getInterfaces() + except: + pass + + try: + return _getInterfacesBSD() + except: + pass + + i = Interface() + i.name = 'FALLBACK' + return [i] + + +def _getInterfaces(): + vals = all_interfaces() + interfaces = [] + for name,ip in vals: + i = Interface() + i.name = name.decode() + i.ip = ip + try: + mask = getSubnetMask(i.name) + i.mask = mask + except: + i.mask = '' + interfaces.append(i) + return interfaces + + +def _getInterfacesBSD(): + #name flags family address netmask + interfaces = [] + from . import getifaddrs + for info in getifaddrs.getifaddrs(): + if info.family == 2: + i = Interface() + i.name = info.name + i.ip = info.address + i.mask = info.netmask + interfaces.append(i) + return interfaces + + +def _getInterfacesWin(): + from . import ipconfig + interfaces = [] + adapters = ipconfig.parse() + for a in adapters: + if not 'IPv4 Address' in a: continue + if not 'Subnet Mask' in a: continue + i = Interface() + i.name = a.get('name','UNKNOWN') + i.ip = a['IPv4 Address'] + i.mask = a['Subnet Mask'] + interfaces.append(i) + return interfaces + + +def _getInterfacesWinPS(): + from . import winpsif + interfaces = [] + + for (name, ip, subnet) in winpsif.getInterfaces(): + i = Interface() + i.name = name + i.ip = ip + i.mask = subnet + interfaces.append(i) + + return interfaces + + +def all_interfaces(): + import sys + import array + import fcntl + + is_64bits = sys.maxsize > 2**32 + struct_size = 40 if is_64bits else 32 + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + max_possible = 8 # initial value + while True: + bytes = max_possible * struct_size + names = array.array('B', b'\0' * bytes) + outbytes = struct.unpack('iL', fcntl.ioctl( + s.fileno(), + 0x8912, # SIOCGIFCONF + struct.pack('iL', bytes, names.buffer_info()[0]) + ))[0] + if outbytes == bytes: + max_possible *= 2 + else: + break + namestr = names.tobytes() + return [(namestr[i:i+16].split(b'\0', 1)[0], + socket.inet_ntoa(namestr[i+20:i+24])) + for i in range(0, outbytes, struct_size)] + +def getSubnetMask(name): + import fcntl + return socket.inet_ntoa(fcntl.ioctl(socket.socket(socket.AF_INET, socket.SOCK_DGRAM), 35099, struct.pack('256s', name))[20:24]) + +def calcIPValue(ipaddr): + """ + Calculates the binary + value of the ip addresse + """ + ipaddr = ipaddr.split('.') + value = 0 + for i in range(len(ipaddr)): + value = value | (int(ipaddr[i]) << ( 8*(3-i) )) + return value + +def calcIPNotation(value): + """ + Calculates the notation + of the ip addresse given its value + """ + notat = [] + for i in range(4): + shift = 255 << ( 8*(3-i) ) + part = value & shift + part = part >> ( 8*(3-i) ) + notat.append(str(part)) + notat = '.'.join(notat) + return notat + +def calcSubnet(cidr): + """ + Calculates the Subnet + based on the CIDR + """ + subn = 4294967295 << (32-cidr) # 4294967295 = all bits set to 1 + subn = subn % 4294967296 # round it back to be 4 bytes + subn = calcIPNotation(subn) + return subn + +def calcCIDR(subnet): + """ + Calculates the CIDR + based on the SUbnet + """ + cidr = 0 + subnet = calcIPValue(subnet) + while subnet != 0: + subnet = subnet << 1 + subnet = subnet % 4294967296 + cidr += 1 + return cidr + +def calcNetpart(ipaddr,subnet): + ipaddr = calcIPValue(ipaddr) + subnet = calcIPValue(subnet) + netpart = ipaddr & subnet + netpart = calcIPNotation(netpart) + return netpart + +def calcMacpart(subnet): + macpart = ~calcIPValue(subnet) + macpart = calcIPNotation(macpart) + return macpart + +def calcBroadcast(ipaddr,subnet): + netpart = calcNetpart(ipaddr,subnet) + macpart = calcMacpart(subnet) + netpart = calcIPValue(netpart) + macpart = calcIPValue(macpart) + broadcast = netpart | macpart + broadcast = calcIPNotation(broadcast) + return broadcast + +def calcDefaultGate(ipaddr,subnet): + defaultgw = calcNetpart(ipaddr,subnet) + defaultgw = calcIPValue(defaultgw) + 1 + defaultgw = calcIPNotation(defaultgw) + return defaultgw + +def calcHostNum(subnet): + macpart = calcMacpart(subnet) + hostnum = calcIPValue(macpart) - 1 + return hostnum \ No newline at end of file diff --git a/script.plexmod/lib/_included_packages/plexnet/netif/getifaddrs.py b/script.plexmod/lib/_included_packages/plexnet/netif/getifaddrs.py new file mode 100644 index 000000000..16f227bd9 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/netif/getifaddrs.py @@ -0,0 +1,192 @@ +""" +Wrapper for getifaddrs(3). +""" + +from __future__ import absolute_import +from __future__ import print_function +import socket +import sys + +from collections import namedtuple +from ctypes import * +from six.moves import map + +class sockaddr_in(Structure): + _fields_ = [ + ('sin_len', c_uint8), + ('sin_family', c_uint8), + ('sin_port', c_uint16), + ('sin_addr', c_uint8 * 4), + ('sin_zero', c_uint8 * 8) + ] + + def __str__(self): + try: + assert self.sin_len >= sizeof(sockaddr_in) + data = ''.join(map(chr, self.sin_addr)) + return socket.inet_ntop(socket.AF_INET, data) + except: + return '' + +class sockaddr_in6(Structure): + _fields_ = [ + ('sin6_len', c_uint8), + ('sin6_family', c_uint8), + ('sin6_port', c_uint16), + ('sin6_flowinfo', c_uint32), + ('sin6_addr', c_uint8 * 16), + ('sin6_scope_id', c_uint32) + ] + + def __str__(self): + try: + assert self.sin6_len >= sizeof(sockaddr_in6) + data = ''.join(map(chr, self.sin6_addr)) + return socket.inet_ntop(socket.AF_INET6, data) + except: + return '' + +class sockaddr_dl(Structure): + _fields_ = [ + ('sdl_len', c_uint8), + ('sdl_family', c_uint8), + ('sdl_index', c_short), + ('sdl_type', c_uint8), + ('sdl_nlen', c_uint8), + ('sdl_alen', c_uint8), + ('sdl_slen', c_uint8), + ('sdl_data', c_uint8 * 12) + ] + + def __str__(self): + assert self.sdl_len >= sizeof(sockaddr_dl) + addrdata = self.sdl_data[self.sdl_nlen:self.sdl_nlen+self.sdl_alen] + return ':'.join('%02x' % x for x in addrdata) + +class sockaddr_storage(Structure): + _fields_ = [ + ('sa_len', c_uint8), + ('sa_family', c_uint8), + ('sa_data', c_uint8 * 254) + ] + +class sockaddr(Union): + _anonymous_ = ('sa_storage', ) + _fields_ = [ + ('sa_storage', sockaddr_storage), + ('sa_sin', sockaddr_in), + ('sa_sin6', sockaddr_in6), + ('sa_sdl', sockaddr_dl), + ] + + def family(self): + return self.sa_storage.sa_family + + def __str__(self): + family = self.family() + if family == socket.AF_INET: + return str(self.sa_sin) + elif family == socket.AF_INET6: + return str(self.sa_sin6) + elif family == 18: # AF_LINK + return str(self.sa_sdl) + else: + print(family) + raise NotImplementedError("address family %d not supported" % family) + + +class ifaddrs(Structure): + pass + +ifaddrs._fields_ = [ + ('ifa_next', POINTER(ifaddrs)), + ('ifa_name', c_char_p), + ('ifa_flags', c_uint), + ('ifa_addr', POINTER(sockaddr)), + ('ifa_netmask', POINTER(sockaddr)), + ('ifa_dstaddr', POINTER(sockaddr)), + ('ifa_data', c_void_p) +] + +# Define constants for the most useful interface flags (from if.h). +IFF_UP = 0x0001 +IFF_BROADCAST = 0x0002 +IFF_LOOPBACK = 0x0008 +IFF_POINTTOPOINT = 0x0010 +IFF_RUNNING = 0x0040 +if sys.platform == 'darwin' or 'bsd' in sys.platform: + IFF_MULTICAST = 0x8000 +elif sys.platform == 'linux': + IFF_MULTICAST = 0x1000 + +# Load library implementing getifaddrs and freeifaddrs. +if sys.platform == 'darwin': + libc = cdll.LoadLibrary('libc.dylib') +else: + libc = cdll.LoadLibrary('libc.so') + +# Tell ctypes the argument and return types for the getifaddrs and +# freeifaddrs functions so it can do marshalling for us. +libc.getifaddrs.argtypes = [POINTER(POINTER(ifaddrs))] +libc.getifaddrs.restype = c_int +libc.freeifaddrs.argtypes = [POINTER(ifaddrs)] + + +def getifaddrs(): + """ + Get local interface addresses. + + Returns generator of tuples consisting of interface name, interface flags, + address family (e.g. socket.AF_INET, socket.AF_INET6), address, and netmask. + The tuple members can also be accessed via the names 'name', 'flags', + 'family', 'address', and 'netmask', respectively. + """ + # Get address information for each interface. + addrlist = POINTER(ifaddrs)() + if libc.getifaddrs(pointer(addrlist)) < 0: + raise OSError + + X = namedtuple('ifaddrs', 'name flags family address netmask') + + # Iterate through the address information. + ifaddr = addrlist + while ifaddr and ifaddr.contents: + # The following is a hack to workaround a bug in FreeBSD + # (PR kern/152036) and MacOSX wherein the netmask's sockaddr may be + # truncated. Specifically, AF_INET netmasks may have their sin_addr + # member truncated to the minimum number of bytes necessary to + # represent the netmask. For example, a sockaddr_in with the netmask + # 255.255.254.0 may be truncated to 7 bytes (rather than the normal + # 16) such that the sin_addr field only contains 0xff, 0xff, 0xfe. + # All bytes beyond sa_len bytes are assumed to be zero. Here we work + # around this truncation by copying the netmask's sockaddr into a + # zero-filled buffer. + if ifaddr.contents.ifa_netmask: + netmask = sockaddr() + memmove(byref(netmask), ifaddr.contents.ifa_netmask, + ifaddr.contents.ifa_netmask.contents.sa_len) + if netmask.sa_family == socket.AF_INET and netmask.sa_len < sizeof(sockaddr_in): + netmask.sa_len = sizeof(sockaddr_in) + else: + netmask = None + + try: + yield X(ifaddr.contents.ifa_name, + ifaddr.contents.ifa_flags, + ifaddr.contents.ifa_addr.contents.family(), + str(ifaddr.contents.ifa_addr.contents), + str(netmask) if netmask else None) + except NotImplementedError: + # Unsupported address family. + yield X(ifaddr.contents.ifa_name, + ifaddr.contents.ifa_flags, + None, + None, + None) + ifaddr = ifaddr.contents.ifa_next + + # When we are done with the address list, ask libc to free whatever memory + # it allocated for the list. + libc.freeifaddrs(addrlist) + +__all__ = ['getifaddrs'] + [n for n in dir() if n.startswith('IFF_')] \ No newline at end of file diff --git a/script.plexmod/lib/_included_packages/plexnet/netif/ipconfig.py b/script.plexmod/lib/_included_packages/plexnet/netif/ipconfig.py new file mode 100644 index 000000000..24804348c --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/netif/ipconfig.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import +from __future__ import print_function +import subprocess +import sys + +def parse(data=None): + kwa = {} + if sys.version_info[0] >= 3: + kwa = {"encoding": "latin-1"} + + data = data or subprocess.check_output('ipconfig /all',startupinfo=getStartupInfo(), **kwa) + dlist = [d.rstrip() for d in data.split('\n')] + mode = None + sections = [] + while dlist: + d = dlist.pop(0) + try: + if not d: + continue + elif not d.startswith(' '): + sections.append({'name':d.strip('.: ')}) + elif d.startswith(' '): + if d.endswith(':'): + k = d.strip(':. ') + mode = 'VALUE:' + k + sections[-1][k] = '' + elif ':' in d: + k,v = d.split(':',1) + k = k.strip(':. ') + mode = 'VALUE:' + k + v = v.replace('(Preferred)','') + sections[-1][k] = v.strip() + elif mode and mode.startswith('VALUE:'): + if not d.startswith(' '): + mode = None + dlist.insert(0,d) + continue + k = mode.split(':',1)[-1] + v = d.replace('(Preferred)','') + sections[-1][k] += ',' + v.strip() + except: + print(d) + raise + + return sections[1:] + +def getStartupInfo(): + if hasattr(subprocess,'STARTUPINFO'): #Windows + startupinfo = subprocess.STARTUPINFO() + try: + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW #Suppress terminal window + except: + startupinfo.dwFlags |= 1 + return startupinfo + + return None \ No newline at end of file diff --git a/script.plexmod/lib/_included_packages/plexnet/netif/winpsif.py b/script.plexmod/lib/_included_packages/plexnet/netif/winpsif.py new file mode 100644 index 000000000..db13eca88 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/netif/winpsif.py @@ -0,0 +1,22 @@ +# coding=utf-8 + +import subprocess +import re +import sys + +PS_RE = re.compile(r'(?mu)^Description\s+: (?P[^\n]+)\nIPAddress\s+: {(?P[0-9.]+),' + r'.+\nIPSubnet\s+: {(?P[0-9.]+),.+\n') + + +def getInterfaces(): + """ + Use powershell to retrieve interfaces; parse using PS_RE, returning tuples of (name, ipaddress, subnet) + """ + kwa = {} + if sys.version_info[0] >= 3: + kwa = {"encoding": "windows-1252"} + + return PS_RE.findall(subprocess.check_output( + 'powershell "Get-WmiObject -Class Win32_NetworkAdapterConfiguration | ' + 'Select-Object Description, IPAddress, IPSubnet | Format-List"', + shell=True, **kwa)) diff --git a/script.plexmod/lib/_included_packages/plexnet/nowplayingmanager.py b/script.plexmod/lib/_included_packages/plexnet/nowplayingmanager.py new file mode 100644 index 000000000..6d043f24c --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/nowplayingmanager.py @@ -0,0 +1,215 @@ +# Most of this is ported from Roku code and much of it is currently unused +# TODO: Perhaps remove unnecessary code +from __future__ import absolute_import +import time + +from . import util +from six import moves +import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error +import six.moves.urllib.parse +from . import plexrequest +from . import callback +from . import http + + +class ServerTimeline(util.AttributeDict): + def reset(self): + self.expires = time.time() + 15 + + def isExpired(self): + return time.time() > self.get('expires', 0) + + +class TimelineData(util.AttributeDict): + def __init__(self, timelineType, *args, **kwargs): + util.AttributeDict.__init__(self, *args, **kwargs) + self.type = timelineType + self.state = "stopped" + self.item = None + self.choice = None + self.playQueue = None + + self.controllable = util.AttributeDict() + self.controllableStr = None + + self.attrs = util.AttributeDict() + + # Set default controllable for all content. Other controllable aspects + # will be set based on the players content. + # + self.setControllable("playPause", True) + self.setControllable("stop", True) + + def setControllable(self, name, isControllable): + if isControllable: + self.controllable[name] = "" + else: + if name in self.controllable: + del self.controllable[name] + + self.controllableStr = None + + def updateControllableStr(self): + if not self.controllableStr: + self.controllableStr = "" + prependComma = False + + for name in self.controllable: + if prependComma: + self.controllableStr += ',' + else: + prependComma = True + self.controllableStr += name + + def toXmlAttributes(self, elem): + self.updateControllableStr() + elem.attrib["type"] = self.type + elem.attrib["start"] = self.state + elem.attrib["controllable"] = self.controllableStr + + if self.item: + if self.item.duration: + elem.attrib['duration'] = self.item.duration + if self.item.ratingKey: + elem.attrib['ratingKey'] = self.item.ratingKey + if self.item.key: + elem.attrib['key'] = self.item.key + if self.item.container.address: + elem.attrib['containerKey'] = self.item.container.address + + # Send the audio, video and subtitle choice if it's available + if self.choice: + for stream in ("audioStream", "videoStream", "subtitleStream"): + if self.choice.get(stream) and self.choice[stream].id: + elem.attrib[stream + "ID"] = self.choice[stream].id + + server = self.item.getServer() + if server: + elem.attrib["machineIdentifier"] = server.uuid + + if server.activeConnection: + parts = six.moves.urllib.parse.uslparse(server.activeConnection.address) + elem.attrib["protocol"] = parts.scheme + elem.attrib["address"] = parts.netloc.split(':', 1)[0] + if ':' in parts.netloc: + elem.attrib["port"] = parts.netloc.split(':', 1)[-1] + elif parts.scheme == 'https': + elem.attrib["port"] = '443' + else: + elem.attrib["port"] = '80' + + if self.playQueue: + elem.attrib["playQueueID"] = str(self.playQueue.id) + elem.attrib["playQueueItemID"] = str(self.playQueue.selectedId) + elem.attrib["playQueueVersion"] = str(self.playQueue.version) + + for key, val in self.attrs.items(): + elem.attrib[key] = val + + +class NowPlayingManager(object): + def __init__(self): + # Constants + self.NAVIGATION = "navigation" + self.FULLSCREEN_VIDEO = "fullScreenVideo" + self.FULLSCREEN_MUSIC = "fullScreenMusic" + self.FULLSCREEN_PHOTO = "fullScreenPhoto" + self.TIMELINE_TYPES = ["video", "music", "photo"] + + # Members + self.serverTimelines = util.AttributeDict() + self.subscribers = util.AttributeDict() + self.pollReplies = util.AttributeDict() + self.timelines = util.AttributeDict() + self.location = self.NAVIGATION + + self.textFieldName = None + self.textFieldContent = None + self.textFieldSecure = None + + # Initialization + for timelineType in self.TIMELINE_TYPES: + self.timelines[timelineType] = TimelineData(timelineType) + + def updatePlaybackState(self, timelineType, playerObject, state, time, playQueue=None, duration=0): + timeline = self.timelines[timelineType] + timeline.state = state + timeline.item = playerObject.item + timeline.choice = playerObject.choice + timeline.playQueue = playQueue + timeline.attrs["time"] = str(time) + timeline.duration = duration + + # self.sendTimelineToAll() + + self.sendTimelineToServer(timelineType, timeline, time) + + def sendTimelineToServer(self, timelineType, timeline, time): + if not hasattr(timeline.item, 'getServer') or not timeline.item.getServer(): + return + + serverTimeline = self.getServerTimeline(timelineType) + + # Only send timeline if it's the first, item changes, playstate changes or timer pops + itemsEqual = timeline.item and serverTimeline.item and timeline.item.ratingKey == serverTimeline.item.ratingKey + if itemsEqual and timeline.state == serverTimeline.state and not serverTimeline.isExpired(): + return + + serverTimeline.reset() + serverTimeline.item = timeline.item + serverTimeline.state = timeline.state + + # Ignore sending timelines for multi part media with no duration + obj = timeline.choice + if obj and obj.part and obj.part.duration.asInt() == 0 and obj.media.parts and len(obj.media.parts) > 1: + util.WARN_LOG("Timeline not supported: the current part doesn't have a valid duration") + return + + # It's possible with timers and in player seeking for the time to be greater than the + # duration, which causes a 400, so in that case we'll set the time to the duration. + duration = timeline.item.duration.asInt() or timeline.duration + if time > duration: + time = duration + + params = util.AttributeDict() + params["time"] = time + params["duration"] = duration + params["state"] = timeline.state + params["guid"] = timeline.item.guid + params["ratingKey"] = timeline.item.ratingKey + params["url"] = timeline.item.url + params["key"] = timeline.item.key + params["containerKey"] = timeline.item.container.address + if timeline.playQueue: + params["playQueueItemID"] = timeline.playQueue.selectedId + + path = "/:/timeline" + for paramKey in params: + if params[paramKey]: + path = http.addUrlParam(path, paramKey + "=" + six.moves.urllib.parse.quote(str(params[paramKey]))) + + request = plexrequest.PlexRequest(timeline.item.getServer(), path) + + context = request.createRequestContext("timelineUpdate", callback.Callable(self.onTimelineResponse)) + context.playQueue = timeline.playQueue + util.APP.startRequest(request, context) + + def getServerTimeline(self, timelineType): + if not self.serverTimelines.get(timelineType): + serverTL = ServerTimeline() + serverTL.reset() + + self.serverTimelines[timelineType] = serverTL + + return self.serverTimelines[timelineType] + + def nowPlayingSetControllable(self, timelineType, name, isControllable): + self.timelines[timelineType].setControllable(name, isControllable) + + def onTimelineResponse(self, request, response, context): + context.request.server.trigger("np:timelineResponse", response=response) + + if not context.playQueue or not context.playQueue.refreshOnTimeline: + return + context.playQueue.refreshOnTimeline = False + context.playQueue.refresh(False) diff --git a/script.plexmod/lib/_included_packages/plexnet/photo.py b/script.plexmod/lib/_included_packages/plexnet/photo.py new file mode 100644 index 000000000..d3a66ca68 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/photo.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +from . import media +from . import plexobjects +from . import plexmedia + + +class Photo(media.MediaItem): + TYPE = 'photo' + + def _setData(self, data): + self.art = plexobjects.PlexValue('') + media.MediaItem._setData(self, data) + + if self.isFullObject(): + self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, + initpath=self.initpath, server=self.server, media=self) + + def analyze(self): + """ The primary purpose of media analysis is to gather information about that media + item. All of the media you add to a Library has properties that are useful to + know–whether it's a video file, a music track, or one of your photos. + """ + self.server.query('/%s/analyze' % self.key) + + def markWatched(self): + path = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey + self.server.query(path) + self.reload() + + def markUnwatched(self): + path = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey + self.server.query(path) + self.reload() + + def play(self, client): + client.playMedia(self) + + def refresh(self): + self.server.query('%s/refresh' % self.key, method=self.server.session.put) + + def isPhotoOrDirectoryItem(self): + return True + + +class PhotoDirectory(media.MediaItem): + TYPE = 'photodirectory' + + def all(self): + path = self.key + return plexobjects.listItems(self.server, path) + + def isPhotoOrDirectoryItem(self): + return True + + +@plexobjects.registerLibFactory('photo') +@plexobjects.registerLibFactory('image') +def PhotoFactory(data, initpath=None, server=None, container=None): + if data.tag == 'Photo': + return Photo(data, initpath=initpath, server=server, container=container) + else: + return PhotoDirectory(data, initpath=initpath, server=server, container=container) diff --git a/script.plexmod/lib/_included_packages/plexnet/playlist.py b/script.plexmod/lib/_included_packages/plexnet/playlist.py new file mode 100644 index 000000000..b06358dd7 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/playlist.py @@ -0,0 +1,183 @@ +from __future__ import absolute_import +import random + +from . import plexobjects +from . import signalsmixin +from six.moves import range + + +class BasePlaylist(plexobjects.PlexObject, signalsmixin.SignalsMixin): + TYPE = 'baseplaylist' + + isRemote = False + + def __init__(self, *args, **kwargs): + plexobjects.PlexObject.__init__(self, *args, **kwargs) + signalsmixin.SignalsMixin.__init__(self) + self._items = [] + self._shuffle = None + self.pos = 0 + self.startShuffled = False + self.isRepeat = False + self.isRepeatOne = False + + def __getitem__(self, idx): + if self._shuffle: + return self._items[self._shuffle[idx]] + else: + return self._items[idx] + + def __iter__(self): + if self._shuffle: + for i in self._shuffle: + yield self._items[i] + else: + for i in self._items: + yield i + + def __len__(self): + return len(self._items) + + def items(self): + if self._shuffle: + return [i for i in self] + else: + return self._items + + def setRepeat(self, repeat, one=False): + if self.isRepeat == repeat and self.isRepeatOne == one: + return + + self.isRepeat = repeat + self.isRepeatOne = one + + def hasNext(self): + if len(self._items) < 2: + return False + if self.isRepeatOne: + return False + if self.isRepeat: + return True + return self.pos < len(self._items) - 1 + + def hasPrev(self): + if len(self._items) < 2: + return False + if self.isRepeatOne: + return False + if self.isRepeat: + return True + return self.pos > 0 + + def next(self): + if not self.hasNext(): + return False + + if self.isRepeatOne: + return True + + self.pos += 1 + if self.pos >= len(self._items): + self.pos = 0 + + return True + + __next__ = next + + def prev(self): + if not self.hasPrev(): + return False + + if self.isRepeatOne: + return True + + self.pos -= 1 + if self.pos < 0: + self.pos = len(self._items) - 1 + + return True + + def getPosFromItem(self, item): + if item not in self._items: + return -1 + return self._items.index(item) + + def setCurrent(self, pos): + if not isinstance(pos, int): + item = pos + pos = self.getPosFromItem(item) + self._items[pos] = item + + if pos < 0 or pos >= len(self._items): + return False + + self.pos = pos + return True + + def current(self): + return self[self.pos] + + def userCurrent(self): + for item in self._items: + if not item.isWatched or item.viewOffset.asInt(): + return item + else: + return self.current() + + def prevItem(self): + if self.pos < 1: + return None + return self[self.pos - 1] + + def shuffle(self, on=True, first=False): + if on and self._items: + self._shuffle = list(range(len(self._items))) + random.shuffle(self._shuffle) + if not first: + self.pos = self._shuffle.index(self.pos) + else: + if self._shuffle: + self.pos = self._shuffle[self.pos] + if not first: + self._shuffle = None + self.trigger('items.changed') + self.refresh() + + def setShuffle(self, shuffle=None): + if shuffle is None: + shuffle = not self.isShuffled + + self.shuffle(shuffle) + + @property + def isShuffled(self): + return bool(self._shuffle) + + def refresh(self, *args, **kwargs): + self.trigger('change') + + +class LocalPlaylist(BasePlaylist): + TYPE = 'localplaylist' + + def __init__(self, items, server, media_item=None): + BasePlaylist.__init__(self, None, server=server) + self._items = items + self._mediaItem = media_item + + def __getattr__(self, name): + if not self._mediaItem: + return BasePlaylist.__getattr__(self, name) + return getattr(self._mediaItem, name) + + def get(self, name, default=''): + if not self._mediaItem: + return BasePlaylist.get(self, name, default) + + return self._mediaItem.get(name, default) + + @property + def defaultArt(self): + if not self._mediaItem: + return super(LocalPlaylist, self).defaultArt + return self._mediaItem.defaultArt diff --git a/script.plexmod/lib/_included_packages/plexnet/playqueue.py b/script.plexmod/lib/_included_packages/plexnet/playqueue.py new file mode 100644 index 000000000..a7adc3b0c --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/playqueue.py @@ -0,0 +1,786 @@ +from __future__ import absolute_import +import re +import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error +import time + +from . import plexapp +from . import plexrequest +from . import callback +from . import plexobjects +from . import util +from . import signalsmixin +from six.moves import range + + +class AudioUsage(object): + def __init__(self, skipsPerHour, playQueueId): + self.HOUR = 3600 + self.skipsPerHour = skipsPerHour + self.playQueueId = playQueueId + self.skips = [] + + def allowSkip(self): + if self.skipsPerHour < 0: + return True + self.updateSkips() + return len(self.skips) < self.skipsPerHour + + def updateSkips(self, reset=False): + if reset or len(self.skips) == 0: + if reset: + self.skips = [] + return + + # Remove old skips if applicable + epoch = util.now() + if self.skips[0] + self.HOUR < epoch: + newSkips = [] + for skip in self.skips: + if skip + self.HOUR > epoch: + newSkips.append(skip) + self.skips = newSkips + self.log("updated skips") + + def registerSkip(self): + self.skips.append(util.now()) + self.updateSkips() + self.log("registered skip") + + def allowSkipMessage(self): + if self.skipsPerHour < 0 or self.allowSkip(): + return None + return "You can skip {0} songs an hour per mix.".format(self.skipsPerHour) + + def log(self, prefix): + util.DEBUG_LOG("AudioUsage {0}: total skips={1}, allowed skips={2}".format(prefix, len(self.skips), self.skipsPerHour)) + + +class UsageFactory(object): + def __init__(self, play_queue): + self.playQueue = play_queue + self.type = play_queue.type + self.usage = play_queue.usage + + @classmethod + def createUsage(cls, playQueue): + obj = cls(playQueue) + + if obj.type: + if obj.type == "audio": + return obj.createAudioUsage() + + util.DEBUG_LOG("Don't know how to usage for " + str(obj.type)) + return None + + def createAudioUsage(self): + skips = self.playQueue.container.stationSkipsPerHour.asInt(-1) + if skips == -1: + return None + + # Create new usage if invalid, or if we start a new PQ, otherwise + # we'll return the existing usage for the PQ. + if not self.usage or self.usage.playQueueId != self.playQueue.id: + self.usage = AudioUsage(skips, self.playQueue.id) + + return self.usage + + +class PlayOptions(util.AttributeDict): + def __init__(self, *args, **kwargs): + util.AttributeDict.__init__(self, *args, **kwargs) + # At the moment, this is really just a glorified struct. But the + # expected fields include key, shuffle, extraPrefixCount, + # and unwatched. We may give this more definition over time. + + # These aren't widely used yet, but half inspired by a PMS discussion... + self.CONTEXT_AUTO = 0 + self.CONTEXT_SELF = 1 + self.CONTEXT_PARENT = 2 + self.CONTEXT_CONTAINER = 3 + + self.context = self.CONTEXT_AUTO + + +def createLocalPlayQueue(item, children, contentType, options): + pass + + +class PlayQueueFactory(object): + def getContentType(self, item): + if item.isMusicOrDirectoryItem(): + return "audio" + elif item.isVideoOrDirectoryItem(): + return "video" + elif item.isPhotoOrDirectoryItem(): + return "photo" + + return None + + def canCreateRemotePlayQueue(self): + if self.item.getServer().isSecondary(): + reason = "Server is secondary" + elif not (self.item.isLibraryItem() or self.item.isGracenoteCollection() or self.item.isLibraryPQ or self.item.isCollection): + reason = "Item is not a library item or gracenote collection" + else: + return True + + util.DEBUG_LOG("Requires local play queue: " + reason) + return False + + def itemRequiresRemotePlayQueue(self): + # TODO(rob): handle entire section? (if we create PQ's of sections) + # return item instanceof PlexSection || item.type == PlexObject.Type.artist; + return self.item.type == "artist" + + +def createPlayQueueForItem(item, children=None, options=None, args=None): + obj = PlayQueueFactory() + + contentType = obj.getContentType(item) + if not contentType: + # TODO(schuyler): We may need to try harder, but I'm not sure yet. For + # example, what if we're shuffling an entire library? + # + # No reason to crash here. We can safely return None and move on. + # We'll stop if we're in dev mode to catch and debug. + # + util.DEBUG_LOG("Don't know how to create play queue for item " + repr(item)) + return None + + obj.item = item + + options = PlayOptions(options or {}) + + if obj.canCreateRemotePlayQueue(): + return createRemotePlayQueue(item, contentType, options, args) + else: + if obj.itemRequiresRemotePlayQueue(): + util.DEBUG_LOG("Can't create remote PQs and item does not support local PQs") + return None + else: + return createLocalPlayQueue(item, children, contentType, options) + + +class PlayQueue(signalsmixin.SignalsMixin): + TYPE = 'playqueue' + + isRemote = True + + def __init__(self, server, contentType, options=None): + signalsmixin.SignalsMixin.__init__(self) + self.id = None + self.selectedId = None + self.version = -1 + self.isShuffled = False + self.isRepeat = False + self.isRepeatOne = False + self.isLocalPlayQueue = False + self.isMixed = None + self.totalSize = 0 + self.windowSize = 0 + self.forcedWindow = False + self.container = None + + # Forced limitations + self.allowShuffle = False + self.allowSeek = True + self.allowRepeat = False + self.allowSkipPrev = False + self.allowSkipNext = False + self.allowAddToQueue = False + + self.refreshOnTimeline = False + + self.server = server + self.type = contentType + self._items = [] + self.options = options or util.AttributeDict() + + self.usage = None + + self.refreshTimer = None + + self.canceled = False + self.responded = False + self.initialized = False + + self.composite = plexobjects.PlexValue('', parent=self) + + # Add a few default options for specific PQ types + if self.type == "audio": + self.options.includeRelated = True + elif self.type == "photo": + self.setRepeat(True) + + def get(self, name): + return getattr(self, name, plexobjects.PlexValue('', parent=self)) + + @property + def defaultArt(self): + return self.current().defaultArt + + def waitForInitialization(self): + start = time.time() + timeout = util.TIMEOUT + util.DEBUG_LOG('Waiting for playQueue to initialize...') + while not self.canceled and not self.initialized: + if not self.responded and time.time() - start > timeout: + util.DEBUG_LOG('PlayQueue timed out wating for initialization') + return self.initialized + time.sleep(0.1) + + if self.initialized: + util.DEBUG_LOG('PlayQueue initialized in {0:.2f} secs: {1}'.format(time.time() - start, self)) + else: + util.DEBUG_LOG('PlayQueue failed to initialize') + + return self.initialized + + def onRefreshTimer(self): + self.refreshTimer = None + self.refresh(True, False) + + def refresh(self, force=True, delay=False, wait=False): + # Ignore refreshing local PQs + if self.isLocal(): + return + + if wait: + self.responded = False + self.initialized = False + # We refresh our play queue if the caller insists or if we only have a + # portion of our play queue loaded. In particular, this means that we don't + # refresh the play queue if we're asked to refresh because a new track is + # being played but we have the entire album loaded already. + + if force or self.isWindowed(): + if delay: + # We occasionally want to refresh the PQ in response to moving to a + # new item and starting playback, but if we refresh immediately: + # we probably end up refreshing before PMS realizes we've moved on. + # There's no great solution, but delaying our refresh by just a few + # seconds makes us much more likely to get an accurate window (and + # accurate selected IDs) from PMS. + + if not self.refreshTimer: + self.refreshTimer = plexapp.createTimer(5000, self.onRefreshTimer) + util.APP.addTimer(self.refreshTimer) + else: + request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id)) + self.addRequestOptions(request) + context = request.createRequestContext("refresh", callback.Callable(self.onResponse)) + util.APP.startRequest(request, context) + + if wait: + return self.waitForInitialization() + + def shuffle(self, shuffle=True): + self.setShuffle(shuffle) + + def setShuffle(self, shuffle=None): + if shuffle is None: + shuffle = not self.isShuffled + + if self.isShuffled == shuffle: + return + + if shuffle: + command = "/shuffle" + else: + command = "/unshuffle" + + # Don't change self.isShuffled, it'll be set in OnResponse if all goes well + + request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + command, "PUT") + self.addRequestOptions(request) + context = request.createRequestContext("shuffle", callback.Callable(self.onResponse)) + util.APP.startRequest(request, context) + + def setRepeat(self, repeat, one=False): + if self.isRepeat == repeat and self.isRepeatOne == one: + return + + self.options.repeat = repeat + self.isRepeat = repeat + self.isRepeatOne = one + + def moveItemUp(self, item): + for index in range(1, len(self._items)): + if self._items[index].get("playQueueItemID") == item.get("playQueueItemID"): + if index > 1: + after = self._items[index - 2] + else: + after = None + + self.swapItem(index, -1) + self.moveItem(item, after) + return True + + return False + + def moveItemDown(self, item): + for index in range(len(self._items) - 1): + if self._items[index].get("playQueueItemID") == item.get("playQueueItemID"): + after = self._items[index + 1] + self.swapItem(index) + self.moveItem(item, after) + return True + + return False + + def moveItem(self, item, after): + if after: + query = "?after=" + after.get("playQueueItemID", "-1") + else: + query = "" + + request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + "/items/" + item.get("playQueueItemID", "-1") + "/move" + query, "PUT") + self.addRequestOptions(request) + context = request.createRequestContext("move", callback.Callable(self.onResponse)) + util.APP.startRequest(request, context) + + def swapItem(self, index, delta=1): + before = self._items[index] + after = self._items[index + delta] + + self._items[index] = after + self._items[index + delta] = before + + def removeItem(self, item): + request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + "/items/" + item.get("playQueueItemID", "-1"), "DELETE") + self.addRequestOptions(request) + context = request.createRequestContext("delete", callback.Callable(self.onResponse)) + util.APP.startRequest(request, context) + + def addItem(self, item, addNext=False, excludeSeedItem=False): + request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id), "PUT") + request.addParam("uri", item.getItemUri()) + request.addParam("next", addNext and "1" or "0") + request.addParam("excludeSeedItem", excludeSeedItem and "1" or "0") + self.addRequestOptions(request) + context = request.createRequestContext("add", callback.Callable(self.onResponse)) + util.APP.startRequest(request, context) + + def onResponse(self, request, response, context): + # Close any loading modal regardless of response status + # Application().closeLoadingModal() + util.DEBUG_LOG('playQueue: Received response') + self.responded = True + if response.parseResponse(): + util.DEBUG_LOG('playQueue: {0} items'.format(len(response.items))) + self.container = response.container + # Handle an empty PQ if we have specified an pqEmptyCallable + if self.options and self.options.pqEmptyCallable: + callable = self.options.pqEmptyCallable + del self.options["pqEmptyCallable"] + if len(response.items) == 0: + callable.call() + return + + self.id = response.container.playQueueID.asInt() + self.isShuffled = response.container.playQueueShuffled.asBool() + self.totalSize = response.container.playQueueTotalCount.asInt() + self.windowSize = len(response.items) + self.version = response.container.playQueueVersion.asInt() + + itemsChanged = False + if len(response.items) == len(self._items): + for i in range(len(self._items)): + if self._items[i] != response.items[i]: + itemsChanged = True + break + else: + itemsChanged = True + + if itemsChanged: + self._items = response.items + + # Process any forced limitations + self.allowSeek = response.container.allowSeek.asBool() + self.allowShuffle = ( + self.totalSize > 1 and response.container.allowShuffle.asBool() and not response.container.playQueueLastAddedItemID + ) + self.allowRepeat = response.container.allowRepeat.asBool() + self.allowSkipPrev = self.totalSize > 1 and response.container.allowSkipPrevious != "0" + self.allowSkipNext = self.totalSize > 1 and response.container.allowSkipNext != "0" + + # Figure out the selected track index and offset. PMS tries to make some + # of this easy, but it might not realize that we've advanced to a new + # track, so we can't blindly trust it. On the other hand, it's possible + # that PMS completely changed the PQ item IDs (e.g. upon shuffling), so + # we might need to use its values. We iterate through the items and try + # to find the item that we believe is selected, only settling for what + # PMS says if we fail. + + playQueueOffset = None + selectedId = None + pmsSelectedId = response.container.playQueueSelectedItemID.asInt() + self.deriveIsMixed() + + # lastItem = None # Not used + for index in range(len(self._items)): + item = self._items[index] + + if not playQueueOffset and item.playQueueItemID.asInt() == pmsSelectedId: + playQueueOffset = response.container.playQueueSelectedItemOffset.asInt() - index + 1 + + # Update the index of everything we've already past, and handle + # wrapping indexes (repeat). + for i in range(index): + pqIndex = playQueueOffset + i + if pqIndex < 1: + pqIndex = pqIndex + self.totalSize + + self._items[i].playQueueIndex = plexobjects.PlexValue(str(pqIndex), parent=self._items[i]) + + if playQueueOffset: + pqIndex = playQueueOffset + index + if pqIndex > self.totalSize: + pqIndex = pqIndex - self.totalSize + + item.playQueueIndex = plexobjects.PlexValue(str(pqIndex), parent=item) + + # If we found the item that we believe is selected: we should + # continue to treat it as selected. + # TODO(schuyler): Should we be checking the metadata ID (rating key) + # instead? I don't think it matters in practice, but it may be + # more correct. + + if not selectedId and item.playQueueItemID.asInt() == self.selectedId: + selectedId = self.selectedId + + if not selectedId: + self.selectedId = pmsSelectedId + + # TODO(schuyler): Set repeat as soon as PMS starts returning it + + # Fix up the container for all our items + response.container.address = "/playQueues/" + str(self.id) + + # Create usage limitations + self.usage = UsageFactory.createUsage(self) + + self.initialized = True + self.trigger("change") + + if itemsChanged: + self.trigger("items.changed") + + def isWindowed(self): + return (not self.isLocal() and (self.totalSize > self.windowSize or self.forcedWindow)) + + def hasNext(self): + if self.isRepeatOne: + return True + + if not self.allowSkipNext and -1 < list(self.items()).index(self.current()) < (len(list(self.items())) - 1): # TODO: Was 'or' - did change cause issues? + return self.isRepeat and not self.isWindowed() + + return True + + def hasPrev(self): + # return self.allowSkipPrev or self.items().index(self.current()) > 0 + return list(self.items()).index(self.current()) > 0 + + def next(self): + if self.isRepeatOne: + return self.current() + + item = self.getNext() + if not item: + return None + + self.selectedId = item.playQueueItemID.asInt() + return item + + __next__ = next + + def prev(self): + if self.isRepeatOne: + return self.current() + + item = self.getPrev() + if not item: + return None + + self.selectedId = item.playQueueItemID.asInt() + return item + + def getPrev(self): + if not self.hasPrev(): + return None + + pos = list(self.items()).index(self.current()) - 1 + return list(self.items())[pos] + + def getNext(self): + if not self.hasNext(): + return None + + pos = list(self.items()).index(self.current()) + 1 + if pos >= len(self.items()): + if not self.isRepeat or self.isWindowed(): + return None + pos = 0 + + return list(self.items())[pos] + + + def setCurrent(self, pos): + if pos < 0 or pos >= len(list(self.items())): + return False + + item = list(self.items())[pos] + self.selectedId = item.playQueueItemID.asInt() + return item + + def setCurrentItem(self, item): + self.selectedId = item.playQueueItemID.asInt() + + def __eq__(self, other): + if not other: + return False + if self.__class__ != other.__class__: + return False + return self.id == other.id and self.type == other.type + + def __ne__(self, other): + return not self.__eq__(other) + + def addRequestOptions(self, request): + boolOpts = ["repeat", "includeRelated"] + for opt in boolOpts: + if self.options.get(opt): + request.addParam(opt, "1") + + intOpts = ["extrasPrefixCount"] + for opt in intOpts: + if self.options.get(opt): + request.addParam(opt, str(self.options.get(opt))) + + includeChapters = self.options.get('includeChapters') is not None and self.options.includeChapters or 1 + request.addParam("includeChapters", str(includeChapters)) + + def __repr__(self): + return ( + str(self.__class__.__name__) + " " + + str(self.type) + " windowSize=" + + str(self.windowSize) + " totalSize=" + + str(self.totalSize) + " selectedId=" + + str(self.selectedId) + " shuffled=" + + str(self.isShuffled) + " repeat=" + + str(self.isRepeat) + " mixed=" + + str(self.isMixed) + " allowShuffle=" + + str(self.allowShuffle) + " version=" + + str(self.version) + " id=" + str(self.id) + ) + + def isLocal(self): + return self.isLocalPlayQueue + + def deriveIsMixed(self): + if self.isMixed is None: + self.isMixed = False + + lastItem = None + for item in self._items: + if not self.isMixed: + if not item.get("parentKey"): + self.isMixed = True + else: + self.isMixed = lastItem and item.get("parentKey") != lastItem.get("parentKey") + + lastItem = item + + def items(self): + return self._items + + def current(self): + for item in self.items(): + if item.playQueueItemID.asInt() == self.selectedId: + return item + + return None + + def prevItem(self): + last = None + for item in self.items(): + if item.playQueueItemID.asInt() == self.selectedId: + return last + last = item + + return None + + +def createRemotePlayQueue(item, contentType, options, args): + util.DEBUG_LOG('Creating remote playQueue request...') + obj = PlayQueue(item.getServer(), contentType, options) + + # The item's URI is made up of the library section UUID, a descriptor of + # the item type (item or directory), and the item's path, URL-encoded. + + uri = "library://" + item.getLibrarySectionUuid() + "/" + itemType = item.isDirectory() and "directory" or "item" + path = None + + if not options.key: + # if item.onDeck and len(item.onDeck) > 0: + # options.key = item.onDeck[0].getAbsolutePath("key") + # el + if not item.isDirectory(): + options.key = item.get("key") + + # If we're asked to play unwatched, ignore the option unless we are unwatched. + options.unwatched = options.unwatched and item.isUnwatched() + + # TODO(schuyler): Until we build postplay, we're not allowed to queue containers for episodes. + if item.type == "episode": + options.context = options.CONTEXT_SELF + elif item.type == "movie": + if not options.extrasPrefixCount and not options.resume: + options.extrasPrefixCount = util.INTERFACE.getPreference("cinema_trailers", 0) + + # How exactly to construct the item URI depends on the metadata type, though + # whenever possible we simply use /library/metadata/:id. + + if item.isLibraryItem() and not item.isLibraryPQ: + path = "/library/metadata/" + item.ratingKey + else: + path = item.getAbsolutePath("key") + + if options.context == options.CONTEXT_SELF: + # If the context is specifically for just this item,: just use the + # item's key and get out. + pass + elif item.type == "playlist": + path = None + uri = item.get("ratingKey") + options.isPlaylist = True + elif item.type == "track": + # TODO(rob): Is there ever a time the container address is wrong? If we + # expect to play a single track,: use options.CONTEXT_SELF. + path = item.container.address or "/library/metadata/" + item.get("parentRatingKey", "") + itemType = "directory" + elif item.isPhotoOrDirectoryItem(): + if item.type == "photoalbum" or item.parentKey: + path = item.getParentPath(item.type == "photoalbum" and "key" or "parentKey") + itemType = "item" + elif item.isDirectory(): + path = item.getAbsolutePath("key") + else: + path = item.container.address + itemType = "directory" + options.key = item.getAbsolutePath("key") + + elif item.type == "episode": + path = "/library/metadata/" + item.get("grandparentRatingKey", "") + itemType = "directory" + options.key = item.getAbsolutePath("key") + # elif item.type == "show": + # path = "/library/metadata/" + item.get("ratingKey", "") + + if path: + if args: + path += util.joinArgs(args) + + util.DEBUG_LOG("playQueue path: " + str(path)) + + if "/search" not in path: + # Convert a few params to the PQ spec + convert = { + 'type': "sourceType", + 'unwatchedLeaves': "unwatched" + } + + for key in convert: + regex = re.compile("(?i)([?&])" + key + "=") + path = regex.sub("\1" + convert[key] + "=", path) + + util.DEBUG_LOG("playQueue path: " + str(path)) + uri = uri + itemType + "/" + six.moves.urllib.parse.quote_plus(path) + + util.DEBUG_LOG("playQueue uri: " + str(uri)) + + # Create the PQ request + request = plexrequest.PlexRequest(obj.server, "/playQueues") + + request.addParam(not options.isPlaylist and "uri" or "playlistID", uri) + request.addParam("type", contentType) + # request.addParam('X-Plex-Client-Identifier', util.INTERFACE.getGlobal('clientIdentifier')) + + # Add options we pass once during PQ creation + if options.shuffle: + request.addParam("shuffle", "1") + options.key = None + else: + request.addParam("shuffle", "0") + + if options.key: + request.addParam("key", options.key) + + # Add options we pass every time querying PQs + obj.addRequestOptions(request) + + util.DEBUG_LOG('Initial playQueue request started...') + context = request.createRequestContext("create", callback.Callable(obj.onResponse)) + util.APP.startRequest(request, context, body='') + + return obj + + +def createPlayQueueForId(id, server=None, contentType=None): + obj = PlayQueue(server, contentType) + obj.id = id + + request = plexrequest.PlexRequest(server, "/playQueues/" + str(id)) + request.addParam("own", "1") + obj.addRequestOptions(request) + context = request.createRequestContext("own", callback.Callable(obj.onResponse)) + util.APP.startRequest(request, context) + + return obj + + +class AudioPlayer(): + pass + + +class VideoPlayer(): + pass + + +class PhotoPlayer(): + pass + + +def addItemToPlayQueue(item, addNext=False): + # See if we have an active play queue for this self.dia type or if we need to + # create one. + + if item.isMusicOrDirectoryItem(): + player = AudioPlayer() + elif item.isVideoOrDirectoryItem(): + player = VideoPlayer() + elif item.isPhotoOrDirectoryItem(): + player = PhotoPlayer() + else: + player = None + + if not player: + util.ERROR_LOG("Don't know how to add item to play queue: " + str(item)) + return None + elif not player.allowAddToQueue(): + util.DEBUG_LOG("Not allowed to add to this player") + return None + + if player.playQueue: + playQueue = player.playQueue + playQueue.addItem(item, addNext) + else: + options = PlayOptions() + options.context = options.CONTEXT_SELF + playQueue = createPlayQueueForItem(item, None, options) + if playQueue: + player.setPlayQueue(playQueue, False) + + return playQueue diff --git a/script.plexmod/lib/_included_packages/plexnet/plexapp.py b/script.plexmod/lib/_included_packages/plexnet/plexapp.py new file mode 100644 index 000000000..cc7d087b1 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexapp.py @@ -0,0 +1,403 @@ +from __future__ import print_function, absolute_import +import threading +import platform +import uuid +import sys + +from . import callback +from . import signalsmixin +from . import simpleobjects +from . import util +import six + +Res = simpleobjects.Res +SERVERMANAGER = None +ACCOUNT = None +MANAGER = None + +PLATFORM = util.X_PLEX_DEVICE + + +def init(): + global MANAGER, SERVERMANAGER, ACCOUNT + from . import myplexaccount + ACCOUNT = myplexaccount.ACCOUNT + ACCOUNT.init() + from . import plexservermanager + SERVERMANAGER = plexservermanager.MANAGER + from . import myplexmanager + util.MANAGER = MANAGER = myplexmanager.MANAGER + ACCOUNT.verifyAccount() + + +class App(signalsmixin.SignalsMixin): + def __init__(self): + signalsmixin.SignalsMixin.__init__(self) + self.pendingRequests = {} + self.initializers = {} + self.timers = [] + from . import nowplayingmanager + self.nowplayingmanager = nowplayingmanager.NowPlayingManager() + + def addTimer(self, timer): + self.timers.append(timer) + + def startRequest(self, request, context, body=None, contentType=None): + context.request = request + + started = request.startAsync(body=body, contentType=contentType, context=context) + + if started: + requestID = context.request.getIdentity() + self.pendingRequests[requestID] = context + elif context.callback: + context.callback(None, context) + + return started + + def onRequestTimeout(self, context): + requestID = context.request.getIdentity() + + if requestID not in self.pendingRequests: + return + + context.request.cancel() + + util.WARN_LOG("Request to {0} timed out after {1} sec".format(util.cleanToken(context.request.url), context.timeout)) + + if context.callback: + context.callback(None, context) + + def delRequest(self, request): + requestID = request.getIdentity() + if requestID not in self.pendingRequests: + return + + del self.pendingRequests[requestID] + + def addInitializer(self, name): + self.initializers[name] = True + + def clearInitializer(self, name): + if name in self.initializers: + del self.initializers[name] + if self.isInitialized(): + self.onInitialized() + + def isInitialized(self): + return not self.initializers + + def onInitialized(self): + # Wire up a few of our own listeners + # PlexServerManager() + # self.on("change:user", callback.Callable(self.onAccountChange)) + + self.trigger('init') + + def cancelAllTimers(self): + for timer in self.timers: + timer.cancel() + + def preShutdown(self): + from . import http + http.HttpRequest._cancel = True + if self.pendingRequests: + util.DEBUG_LOG('Closing down {0} App() requests...'.format(len(self.pendingRequests))) + for k in list(self.pendingRequests.keys()): + p = self.pendingRequests.get(k) + if p: + p.request.cancel() + + if self.timers: + util.DEBUG_LOG('Canceling App() timers...') + self.cancelAllTimers() + + if SERVERMANAGER.selectedServer: + util.DEBUG_LOG('Closing server...') + SERVERMANAGER.selectedServer.close() + + def shutdown(self): + if self.timers: + util.DEBUG_LOG('Waiting for {0} App() timers: Started'.format(len(self.timers))) + + self.cancelAllTimers() + + for timer in self.timers: + timer.join() + + util.DEBUG_LOG('Waiting for App() timers: Finished') + + +class DeviceInfo(object): + def getCaptionsOption(self, key): + return None + + +class AppInterface(object): + QUALITY_LOCAL = 0 + QUALITY_REMOTE = 1 + QUALITY_ONLINE = 2 + + _globals = {} + + def __init__(self): + self.setQualities() + + def setQualities(self): + # Calculate the max quality based on 4k support + if self._globals.get("supports4k"): + maxQuality = simpleobjects.AttributeDict({ + 'height': 2160, + 'maxHeight': 2160, + 'origHeight': 1080 + }) + maxResolution = self._globals.get("Is4k") and "4k" or "1080p" + else: + maxQuality = simpleobjects.AttributeDict({ + 'height': 1080, + 'maxHeight': 1088 + }) + maxResolution = "1080p" + + self._globals['qualities'] = [ + simpleobjects.AttributeDict({'title': "Original", 'index': 13, 'maxBitrate': 1000000}), + simpleobjects.AttributeDict({'title': "20 Mbps " + maxResolution, 'index': 12, 'maxBitrate': 20000}), + simpleobjects.AttributeDict({'title': "12 Mbps " + maxResolution, 'index': 11, 'maxBitrate': 12000}), + simpleobjects.AttributeDict({'title': "10 Mbps " + maxResolution, 'index': 10, 'maxBitrate': 10000}), + simpleobjects.AttributeDict({'title': "8 Mbps " + maxResolution, 'index': 9, 'maxBitrate': 8000}), + simpleobjects.AttributeDict({'title': "4 Mbps 720p", 'index': 8, 'maxBitrate': 4000, 'maxHeight': 720}), + simpleobjects.AttributeDict({'title': "3 Mbps 720p", 'index': 7, 'maxBitrate': 3000, 'maxHeight': 720}), + simpleobjects.AttributeDict({'title': "2 Mbps 720p", 'index': 6, 'maxBitrate': 2000, 'maxHeight': 720}), + simpleobjects.AttributeDict({'title': "1.5 Mbps 480p", 'index': 5, 'maxBitrate': 1500, 'maxHeight': 480}), + simpleobjects.AttributeDict({'title': "720 Kbps", 'index': 4, 'maxBitrate': 720, 'maxHeight': 360}), + simpleobjects.AttributeDict({'title': "320 Kbps", 'index': 3, 'maxBitrate': 320, 'maxHeight': 360}), + maxQuality + ] + + for quality in self._globals['qualities']: + if quality.index is not None and quality.index >= 9: + quality.update(maxQuality) + + def getPreference(self, pref, default=None): + raise NotImplementedError + + def setPreference(self, pref, value): + raise NotImplementedError + + def clearRegistry(self, reg, sec=None): + raise NotImplementedError + + def getRegistry(self, reg, default=None, sec=None): + raise NotImplementedError + + def setRegistry(self, reg, value, sec=None): + raise NotImplementedError + + def getGlobal(self, glbl, default=None): + raise NotImplementedError + + def getCapabilities(self): + raise NotImplementedError + + def LOG(self, msg): + raise NotImplementedError + + def DEBUG_LOG(self, msg): + self.LOG(msg) + + def WARN_LOG(self, msg): + self.LOG(msg) + + def ERROR_LOG(self, msg): + self.LOG(msg) + + def ERROR(self, msg=None, err=None): + self.LOG(msg) + + def FATAL(self, msg=None): + self.ERROR_LOG('FATAL: {0}'.format(msg)) + + def supportsAudioStream(self, codec, channels): + return False + + def supportsSurroundSound(self): + return False + + def getMaxResolution(self, quality_type, allow4k=False): + return 480 + + def getQualityIndex(self, qualityType): + if qualityType == self.QUALITY_LOCAL: + return self.getPreference("local_quality", 13) + elif qualityType == self.QUALITY_ONLINE: + return self.getPreference("online_quality", 13) + else: + return self.getPreference("remote_quality", 13) + + def settingsGetMaxResolution(self, qualityType, allow4k): + qualityIndex = self.getQualityIndex(qualityType) + + if qualityIndex >= 9: + return allow4k and 2160 or 1088 + elif qualityIndex >= 6: + return 720 + elif qualityIndex >= 5: + return 480 + else: + return 360 + + def getMaxBitrate(self, qualityType): + qualityIndex = self.getQualityIndex(qualityType) + + qualities = self.getGlobal("qualities", []) + for quality in qualities: + if quality.index == qualityIndex: + return util.validInt(quality.maxBitrate) + + return 0 + + +class PlayerSettingsInterface(object): + def __init__(self): + self.prefOverrides = {} + + def __getattr__(self, name): + return getattr(util.INTERFACE, name) + + def setPrefOverride(self, key, val): + self.prefOverrides[key] = val + + def getPrefOverride(self, key, default=None): + return self.prefOverrides.get(key, default) + + def getQualityIndex(self, qualityType): + if qualityType == util.INTERFACE.QUALITY_LOCAL: + return self.getPreference("local_quality", 13) + elif qualityType == util.INTERFACE.QUALITY_ONLINE: + return self.getPreference("online_quality", 13) + else: + return self.getPreference("remote_quality", 13) + + def getPreference(self, key, default=None): + if key in self.prefOverrides: + return self.prefOverrides[key] + else: + return util.INTERFACE.getPreference(key, default) + + def getMaxResolution(self, quality_type, allow4k=False): + qualityIndex = self.getQualityIndex(quality_type) + + if qualityIndex >= 9: + return allow4k and 2160 or 1088 + elif qualityIndex >= 6: + return 720 + elif qualityIndex >= 5: + return 480 + else: + return 360 + + def getMaxBitrate(self, qualityType): + qualityIndex = self.getQualityIndex(qualityType) + + qualities = self.getGlobal("qualities", []) + for quality in qualities: + if quality.index == qualityIndex: + return util.validInt(quality.maxBitrate) + + return 0 + + +class DumbInterface(AppInterface): + _prefs = {} + _regs = { + None: {} + } + _globals = { + 'platform': platform.uname()[0], + 'appVersionStr': '0.0.0a1', + 'clientIdentifier': str(hex(uuid.getnode())), + 'platformVersion': platform.uname()[2], + 'product': 'PlexNet.API', + 'provides': 'player', + 'device': platform.uname()[0], + 'model': 'Unknown', + 'friendlyName': 'PlexNet.API', + 'deviceInfo': DeviceInfo() + } + + def getPreference(self, pref, default=None): + return self._prefs.get(pref, default) + + def setPreference(self, pref, value): + self._prefs[pref] = value + + def getRegistry(self, reg, default=None, sec=None): + section = self._regs.get(sec) + if section: + return section.get(reg, default) + + return default + + def setRegistry(self, reg, value, sec=None): + if sec and sec not in self._regs: + self._regs[sec] = {} + self._regs[sec][reg] = value + + def clearRegistry(self, reg, sec=None): + del self._regs[sec][reg] + + def getGlobal(self, glbl, default=None): + return self._globals.get(glbl, default) + + def getCapabilities(self): + return '' + + def LOG(self, msg): + print('PlexNet.API: {0}'.format(msg)) + + def DEBUG_LOG(self, msg): + self.LOG('DEBUG: {0}'.format(msg)) + + def WARN_LOG(self, msg): + self.LOG('WARNING: {0}'.format(msg)) + + def ERROR_LOG(self, msg): + self.LOG('ERROR: {0}'.format(msg)) + + def ERROR(self, msg=None, err=None): + if err: + self.LOG('ERROR: {0} - {1}'.format(msg, err.message)) + else: + import traceback + traceback.print_exc() + + +def createTimer(timeout, function, repeat=False, *args, **kwargs): + if isinstance(function, six.string_types): + def dummy(*args, **kwargs): + pass + dummy.__name__ = function + function = dummy + timer = util.TIMER(timeout / 1000.0, function, repeat=repeat, *args, **kwargs) + return timer + + +def setUserAgent(agent): + util.USER_AGENT = agent + util.BASE_HEADERS = util.resetBaseHeaders() + + +def setAbortFlagFunction(func): + from . import asyncadapter + asyncadapter.ABORT_FLAG_FUNCTION = func + + +def refreshResources(force=False): + from . import gdm + gdm.DISCOVERY.discover() + util.MANAGER.refreshResources(force) + SERVERMANAGER.refreshManualConnections() + + +util.setApp(App()) +util.setInterface(DumbInterface()) diff --git a/script.plexmod/lib/_included_packages/plexnet/plexconnection.py b/script.plexmod/lib/_included_packages/plexnet/plexconnection.py new file mode 100644 index 000000000..617a2d146 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexconnection.py @@ -0,0 +1,284 @@ +from __future__ import absolute_import +import random +import socket + +from . import http +from . import callback +from . import util + +HAS_ICMPLIB = False +try: + from icmplib import ping, resolve, ICMPLibError +except: + pass +else: + HAS_ICMPLIB = True + from urllib.parse import urlparse + from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network + + # local networks + LOCAL_NETWORKS = { + 4: [IPv4Network('10.0.0.0/8'), IPv4Network('192.168.0.0/16'), IPv4Network('127.0.0.0/8')], + 6: [IPv6Network('fd00::/8')] + } + + +class ConnectionSource(int): + def init(self, name): + self.name = name + return self + + def __repr__(self): + return self.name + + +class PlexConnection(object): + # Constants + STATE_UNKNOWN = "unknown" + STATE_UNREACHABLE = "unreachable" + STATE_REACHABLE = "reachable" + STATE_UNAUTHORIZED = "unauthorized" + STATE_INSECURE = "insecure_untested" + + SOURCE_MANUAL = ConnectionSource(1).init('MANUAL') + SOURCE_DISCOVERED = ConnectionSource(2).init('DISCOVERED') + SOURCE_MANUAL_AND_DISCOVERED = ConnectionSource(3).init('MANUAL, DISCOVERED') + SOURCE_MYPLEX = ConnectionSource(4).init('MYPLEX') + SOURCE_MANUAL_AND_MYPLEX = ConnectionSource(5).init('MANUAL, MYPLEX') + SOURCE_DISCOVERED_AND_MYPLEX = ConnectionSource(6).init('DISCOVERED, MYPLEX') + SOURCE_ALL = ConnectionSource(7).init('ALL') + + SCORE_REACHABLE = 4 + SCORE_LOCAL = 2 + SCORE_SECURE = 1 + + SOURCE_BY_VAL = { + 1: SOURCE_MANUAL, + 2: SOURCE_DISCOVERED, + 3: SOURCE_MANUAL_AND_DISCOVERED, + 4: SOURCE_MYPLEX, + 5: SOURCE_MANUAL_AND_MYPLEX, + 6: SOURCE_DISCOVERED_AND_MYPLEX, + 7: SOURCE_ALL + } + + def __init__(self, source, address, isLocal, token, isFallback=False, skipLocalCheck=False): + self.state = self.STATE_UNKNOWN + self.sources = source + self.address = address + self.isLocal = isLocal + self.isSecure = address[:5] == 'https' + self.isFallback = isFallback + self.token = token + self.refreshed = True + self.score = 0 + self.request = None + + self.lastTestedAt = 0 + self.hasPendingRequest = False + + self.isSecureButLocal = False + + if not HAS_ICMPLIB: + util.WARN_LOG("icmplib not found, can't check local connectivity") + + # check whether hostname is on LAN + if HAS_ICMPLIB and util.CHECK_LOCAL and not skipLocalCheck: + self.checkLocal() + + self.getScore(True) + + def __eq__(self, other): + if not other: + return False + if self.__class__ != other.__class__: + return False + return self.address == other.address + + def __ne__(self, other): + return not self.__eq__(other) + + def __str__(self): + return "Connection: {0} local: {1} token: {2} sources: {3} state: {4} score: {5}".format( + self.address, + self.isLocal, + util.hideToken(self.token), + repr(self.sources), + self.state, + self.getScore() + ) + + def __repr__(self): + return self.__str__() + + def ipInLocalNet(self, ip): + key = ":" in ip and 6 or 4 + addr = key == 4 and IPv4Address(ip) or IPv6Address(ip) + for network in LOCAL_NETWORKS[key]: + if addr in network: + return network + return False + + def checkLocal(self): + pUrl = urlparse(self.address) + hostname = pUrl.hostname + try: + ips = resolve(hostname) + except (socket.gaierror, ICMPLibError): + return False + + for ip in ips: + if ip == hostname: + continue + + network = self.ipInLocalNet(ip) + if not network: + continue + + try: + host = ping(ip, count=1, interval=1, timeout=util.LAN_REACHABILITY_TIMEOUT, privileged=False) + except: + continue + + if host.is_alive: + self.isLocal = True + util.LOG("Found IP {0} in local network ({1}) when checking {2}. Ping: {3}ms (max: {4}ms)" + .format(ip, network, self.address, host.max_rtt, int(util.LAN_REACHABILITY_TIMEOUT * 1000))) + + if self.isSecure: + # alert the server that we've found the IP locally, so we can test non-secure connectivity + self.isSecureButLocal = (ip, pUrl.port) + + return False + + def merge(self, other): + # plex.tv trumps all, otherwise assume newer is better + # ROKU: if (other.sources and self.SOURCE_MYPLEX) <> 0 then + if other.sources == self.SOURCE_MYPLEX: + self.token = other.token + else: + self.token = self.token or other.token + + self.address = other.address + self.sources = self.SOURCE_BY_VAL[self.sources | other.sources] + self.isLocal = self.isLocal | other.isLocal + self.isSecure = other.isSecure + self.isFallback = self.isFallback or other.isFallback + self.refreshed = True + + self.getScore(True) + + def testReachability(self, server, allowFallback=False): + # Check if we will allow the connection test. If this is a fallback connection, + # then we will defer it until we "allowFallback" (test insecure connections + # after secure tests have completed and failed). Insecure connections will be + # tested if the policy "always" allows them, or if set to "same_network" and + # the current connection is local and server has (publicAddressMatches=1). + insecurePolicy = util.INTERFACE.getPreference("allow_insecure") + insecureAllowed = insecurePolicy == "always" or (insecurePolicy == "same_network" and + server.sameNetwork and self.isLocal) + + allowConnectionTest = not self.isFallback or (util.LOCAL_OVER_SECURE and insecureAllowed) + if not allowConnectionTest: + if insecureAllowed: + allowConnectionTest = allowFallback + server.hasFallback = not allowConnectionTest + util.LOG( + '{0} for {1}'.format( + allowConnectionTest and "Continuing with insecure connection testing" or "Insecure connection testing is deferred", server + ) + ) + else: + util.LOG("Insecure connections not allowed. Ignore insecure connection test for {0}".format(server)) + self.state = self.STATE_INSECURE + callable = callback.Callable(server.onReachabilityResult, [self], random.randint(0, 256)) + callable.deferCall() + return True + + if allowConnectionTest: + if not self.isSecure and not util.LOCAL_OVER_SECURE and ( + not allowFallback and + server.hasSecureConnections() or + server.activeConnection and + server.activeConnection.state != self.STATE_REACHABLE and + server.activeConnection.isSecure + ): + util.DEBUG_LOG("Invalid insecure connection test in progress") + self.request = http.HttpRequest(self.buildUrl(server, "/")) + context = self.request.createRequestContext("reachability", callback.Callable(self.onReachabilityResponse), + timeout=util.CONN_CHECK_TIMEOUT) + context.server = server + util.addPlexHeaders(self.request, server.getToken()) + self.hasPendingRequest = util.APP.startRequest(self.request, context) + util.DEBUG_LOG("Testing insecure connection test for: {0}".format(server)) + return True + + return False + + def cancelReachability(self): + if self.request: + self.request.ignoreResponse = True + self.request.cancel() + + def onReachabilityResponse(self, request, response, context): + self.hasPendingRequest = False + # It's possible we may have a result pending before we were able + # to cancel it, so we'll just ignore it. + + # if request.ignoreResponse: + # return + + if response.isSuccess(): + data = response.getBodyXml() + if data is not None and context.server.collectDataFromRoot(data): + self.state = self.STATE_REACHABLE + else: + # This is unexpected, but treat it as unreachable + util.ERROR_LOG("Unable to parse root response from {0}".format(context.server)) + self.state = self.STATE_UNREACHABLE + elif response.getStatus() == 401: + self.state = self.STATE_UNAUTHORIZED + else: + self.state = self.STATE_UNREACHABLE + + self.getScore(True) + + context.server.onReachabilityResult(self) + + def buildUrl(self, server, path, includeToken=False): + if '://' in path: + url = path + else: + url = self.address + path + + if includeToken: + # If we have a token, use it. Otherwise see if any other connections + # for this server have one. That will let us use a plex.tv token for + # something like a manually configured connection. + + token = self.token or server.getToken() + + if token: + url = http.addUrlParam(url, "X-Plex-Token=" + token) + + return url + + def simpleBuildUrl(self, server, path): + token = (self.token or server.getToken()) + param = '' + if token: + param = '&X-Plex-Token={0}'.format(token) + + return '{0}{1}{2}'.format(self.address, path, param) + + def getScore(self, recalc=False): + if recalc: + self.score = 0 + if self.state == self.STATE_REACHABLE: + self.score += self.SCORE_REACHABLE + if self.isSecure: + self.score += self.SCORE_SECURE + if self.isLocal: + self.score += self.SCORE_LOCAL + (not self.isSecure and util.LOCAL_OVER_SECURE and 2 or 0) + + return self.score diff --git a/script.plexmod/lib/_included_packages/plexnet/plexlibrary.py b/script.plexmod/lib/_included_packages/plexnet/plexlibrary.py new file mode 100644 index 000000000..688115d49 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexlibrary.py @@ -0,0 +1,632 @@ +# -*- coding: utf-8 -*- +""" +PlexLibrary +""" +from __future__ import absolute_import +import re +from . import plexobjects +from . import playlist +from . import media +from . import exceptions +from . import util +from . import signalsmixin +from six.moves import map + + +class Library(plexobjects.PlexObject): + def __repr__(self): + return ''.format(self.title1.encode('utf8')) + + def sections(self): + items = [] + + path = '/library/sections' + for elem in self.server.query(path): + stype = elem.attrib['type'] + if stype in SECTION_TYPES: + cls = SECTION_TYPES[stype] + items.append(cls(elem, initpath=path, server=self.server, container=self)) + return items + + def section(self, title=None): + for item in self.sections(): + if item.title == title: + return item + raise exceptions.NotFound('Invalid library section: %s' % title) + + def all(self): + return plexobjects.listItems(self.server, '/library/all') + + def onDeck(self): + return plexobjects.listItems(self.server, '/library/onDeck') + + def recentlyAdded(self): + return plexobjects.listItems(self.server, '/library/recentlyAdded') + + def get(self, title): + return plexobjects.findItem(self.server, '/library/all', title) + + def getByKey(self, key): + return plexobjects.findKey(self.server, key) + + def search(self, title, libtype=None, **kwargs): + """ Searching within a library section is much more powerful. It seems certain attributes on the media + objects can be targeted to filter this search down a bit, but I havent found the documentation for + it. For example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items + such as actor= seem to work, but require you already know the id of the actor. + TLDR: This is untested but seems to work. Use library section search when you can. + """ + args = {} + if title: + args['title'] = title + if libtype: + args['type'] = plexobjects.searchType(libtype) + for attr, value in kwargs.items(): + args[attr] = value + query = '/library/all%s' % util.joinArgs(args) + return plexobjects.listItems(self.server, query) + + def cleanBundles(self): + self.server.query('/library/clean/bundles') + + def emptyTrash(self): + for section in self.sections(): + section.emptyTrash() + + def optimize(self): + self.server.query('/library/optimize') + + def refresh(self): + self.server.query('/library/sections/all/refresh') + + def randomArts(self): + return plexobjects.listItems(self.server, '/library/arts?sort=random&type=1%2c2%2c8&X-Plex-Container-Start=0&X-Plex-Container-Size=50') + + +class LibrarySection(plexobjects.PlexObject): + ALLOWED_FILTERS = () + ALLOWED_SORT = () + BOOLEAN_FILTERS = ('unwatched', 'duplicate') + + isLibraryPQ = True + + def __repr__(self): + title = self.title.replace(' ', '.')[0:20] + return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8')) + + @staticmethod + def fromFilter(filter_): + cls = SECTION_IDS.get(filter_.getLibrarySectionType()) + if not cls: + return + section = cls(None, initpath=filter_.initpath, server=filter_.server, container=filter_.container) + section.key = filter_.getLibrarySectionId() + section.title = filter_.reasonTitle + section.type = cls.TYPE + return section + + def reload(self, **kwargs): + """ Reload the data for this object from PlexServer XML. """ + initpath = '/library/sections/{0}'.format(self.key) + key = self.key + try: + data = self.server.query(initpath, params=kwargs) + except Exception as e: + import traceback + traceback.print_exc() + util.ERROR(err=e) + self.initpath = self.key + return + + self._setData(data[0]) + self.initpath = self.key = key + + def isDirectory(self): + return True + + def isLibraryItem(self): + return True + + def getAbsolutePath(self, key): + if key == 'key': + return '/library/sections/{0}/all'.format(self.key) + + return plexobjects.PlexObject.getAbsolutePath(self, key) + + def all(self, start=None, size=None, filter_=None, sort=None, unwatched=False, type_=None): + if self.key.startswith('/'): + path = '{0}/all'.format(self.key) + else: + path = '/library/sections/{0}/all'.format(self.key) + + return self.items(path, start, size, filter_, sort, unwatched, type_, False) + + def folder(self, start=None, size=None, subDir=False): + if self.key.startswith('/'): + path = self.key + else: + path = '/library/sections/{0}'.format(self.key) + + if not subDir: + path = '{0}/folder'.format(path) + + return self.items(path, start, size, None, None, False, None, True) + + def items(self, path, start, size, filter_, sort, unwatched, type_, tag_fallback): + + args = {"includeCollections" : "1"} + + if size is not None: + args['X-Plex-Container-Start'] = start + args['X-Plex-Container-Size'] = size + + if filter_: + args[filter_[0]] = filter_[1] + + if sort: + args['sort'] = '{0}:{1}'.format(*sort) + + if type_: + args['type'] = str(type_) + + if unwatched: + args[self.TYPE == 'movie' and 'unwatched' or 'unwatchedLeaves'] = 1 + + if args: + path += util.joinArgs(args, '?' not in path) + + return plexobjects.listItems(self.server, path, tag_fallback=tag_fallback) + + def jumpList(self, filter_=None, sort=None, unwatched=False, type_=None): + if self.key.startswith('/'): + path = '{0}/firstCharacter'.format(self.key) + else: + path = '/library/sections/{0}/firstCharacter'.format(self.key) + + args = {"includeCollections" : "1"} + + if filter_: + args[filter_[0]] = filter_[1] + + if sort: + args['sort'] = '{0}:{1}'.format(*sort) + + if type_: + args['type'] = str(type_) + + if unwatched: + args[self.TYPE == 'movie' and 'unwatched' or 'unwatchedLeaves'] = 1 + + if args: + path += util.joinArgs(args) + + try: + return plexobjects.listItems(self.server, path, bytag=True) + except exceptions.BadRequest: + util.ERROR('jumpList() request error for path: {0}'.format(repr(path))) + return None + + @property + def onDeck(self): + return plexobjects.listItems(self.server, '/library/sections/%s/onDeck' % self.key) + + def analyze(self): + self.server.query('/library/sections/%s/analyze' % self.key) + + def emptyTrash(self): + self.server.query('/library/sections/%s/emptyTrash' % self.key) + + def refresh(self): + self.server.query('/library/sections/%s/refresh' % self.key) + + def listChoices(self, category, libtype=None, **kwargs): + """ List choices for the specified filter category. kwargs can be any of the same + kwargs in self.search() to help narrow down the choices to only those that + matter in your current context. + """ + if category in kwargs: + raise exceptions.BadRequest('Cannot include kwarg equal to specified category: %s' % category) + args = {} + for subcategory, value in kwargs.items(): + args[category] = self._cleanSearchFilter(subcategory, value) + if libtype is not None: + args['type'] = plexobjects.searchType(libtype) + query = '/library/sections/%s/%s%s' % (self.key, category, util.joinArgs(args)) + + return plexobjects.listItems(self.server, query, bytag=True) + + def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs): + """ Search the library. If there are many results, they will be fetched from the server + in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first + results, it would be wise to set the maxresults option to that amount so this functions + doesn't iterate over all results on the server. + title: General string query to search for. + sort: column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt, + titleSort, rating, mediaHeight, duration}. dir can be asc or desc. + maxresults: Only return the specified number of results + libtype: Filter results to a spcifiec libtype {movie, show, episode, artist, album, track} + kwargs: Any of the available filters for the current library section. Partial string + matches allowed. Multiple matches OR together. All inputs will be compared with the + available options and a warning logged if the option does not appear valid. + 'unwatched': Display or hide unwatched content (True, False). [all] + 'duplicate': Display or hide duplicate items (True, False). [movie] + 'actor': List of actors to search ([actor_or_id, ...]). [movie] + 'collection': List of collections to search within ([collection_or_id, ...]). [all] + 'contentRating': List of content ratings to search within ([rating_or_key, ...]). [movie, tv] + 'country': List of countries to search within ([country_or_key, ...]). [movie, music] + 'decade': List of decades to search within ([yyy0, ...]). [movie] + 'director': List of directors to search ([director_or_id, ...]). [movie] + 'genre': List Genres to search within ([genere_or_id, ...]). [all] + 'network': List of TV networks to search within ([resolution_or_key, ...]). [tv] + 'resolution': List of video resolutions to search within ([resolution_or_key, ...]). [movie] + 'studio': List of studios to search within ([studio_or_key, ...]). [music] + 'year': List of years to search within ([yyyy, ...]). [all] + """ + # Cleanup the core arguments + args = {} + for category, value in kwargs.items(): + args[category] = self._cleanSearchFilter(category, value, libtype) + if title is not None: + args['title'] = title + if sort is not None: + args['sort'] = self._cleanSearchSort(sort) + if libtype is not None: + args['type'] = plexobjects.searchType(libtype) + # Iterate over the results + results, subresults = [], '_init' + args['X-Plex-Container-Start'] = 0 + args['X-Plex-Container-Size'] = min(util.X_PLEX_CONTAINER_SIZE, maxresults) + while subresults and maxresults > len(results): + query = '/library/sections/%s/all%s' % (self.key, util.joinArgs(args)) + subresults = plexobjects.listItems(self.server, query) + results += subresults[:maxresults - len(results)] + args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] + return results + + def _cleanSearchFilter(self, category, value, libtype=None): + # check a few things before we begin + if category not in self.ALLOWED_FILTERS: + raise exceptions.BadRequest('Unknown filter category: %s' % category) + if category in self.BOOLEAN_FILTERS: + return '1' if value else '0' + if not isinstance(value, (list, tuple)): + value = [value] + # convert list of values to list of keys or ids + result = set() + choices = self.listChoices(category, libtype) + lookup = {} + for c in choices: + lookup[c.title.lower()] = c.key + + allowed = set(c.key for c in choices) + for item in value: + item = str(item.id if isinstance(item, media.MediaTag) else item).lower() + # find most logical choice(s) to use in url + if item in allowed: + result.add(item) + continue + if item in lookup: + result.add(lookup[item]) + continue + matches = [k for t, k in lookup.items() if item in t] + if matches: + list(map(result.add, matches)) + continue + # nothing matched; use raw item value + util.LOG('Filter value not listed, using raw item value: {0}'.format(item)) + result.add(item) + return ','.join(result) + + def _cleanSearchSort(self, sort): + sort = '%s:asc' % sort if ':' not in sort else sort + scol, sdir = sort.lower().split(':') + lookup = {} + for s in self.ALLOWED_SORT: + lookup[s.lower()] = s + if scol not in lookup: + raise exceptions.BadRequest('Unknown sort column: %s' % scol) + if sdir not in ('asc', 'desc'): + raise exceptions.BadRequest('Unknown sort dir: %s' % sdir) + return '%s:%s' % (lookup[scol], sdir) + + +class MovieSection(LibrarySection): + ALLOWED_FILTERS = ( + 'unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection', + 'director', 'actor', 'country', 'studio', 'resolution' + ) + ALLOWED_SORT = ( + 'addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating', 'audienceRating', 'userRating', + 'contentRating', 'mediaHeight', 'duration' + ) + TYPE = 'movie' + ID = '1' + + +class ShowSection(LibrarySection): + ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection') + ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort', 'rating', 'audienceRating', + 'userRating', 'contentRating', 'unwatched') + TYPE = 'show' + ID = '2' + + def searchShows(self, **kwargs): + return self.search(libtype='show', **kwargs) + + def searchEpisodes(self, **kwargs): + return self.search(libtype='episode', **kwargs) + + +class MusicSection(LibrarySection): + ALLOWED_FILTERS = ('genre', 'country', 'collection') + ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort') + TYPE = 'artist' + ID = '8' + + def searchShows(self, **kwargs): + return self.search(libtype='artist', **kwargs) + + def searchEpisodes(self, **kwargs): + return self.search(libtype='album', **kwargs) + + def searchTracks(self, **kwargs): + return self.search(libtype='track', **kwargs) + + +class PhotoSection(LibrarySection): + ALLOWED_FILTERS = () + ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort') + TYPE = 'photo' + ID = 'None' + + def isPhotoOrDirectoryItem(self): + return True + + +@plexobjects.registerLibType +class Collection(media.MediaItem): + TYPE = 'collection' + + def __repr__(self): + title = self.title.replace(' ', '.')[0:20] + return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.key, title) + + def all(self, *args, **kwargs): + items = plexobjects.listItems(self.server, self.key) + items.totalSize = items.size + return items + + def isMusicOrDirectoryItem(self): + return self.container.viewGroup in ('artist', 'album', 'track') + + def isVideoOrDirectoryItem(self): + return self.container.viewGroup in ('movie', 'show', 'episode') + + def isCollection(self): + return True + + +@plexobjects.registerLibType +class Generic(plexobjects.PlexObject): + TYPE = 'Directory' + + def __repr__(self): + title = self.title.replace(' ', '.')[0:20] + return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.key, title) + +#@plexobjects.registerLibType +#class Collection(Generic): +# TYPE = 'collection' + +@plexobjects.registerLibType +class Playlist(playlist.BasePlaylist, signalsmixin.SignalsMixin): + TYPE = 'playlist' + + def __init__(self, *args, **kwargs): + playlist.BasePlaylist.__init__(self, *args, **kwargs) + signalsmixin.SignalsMixin.__init__(self) + self._itemsLoaded = False + + def __repr__(self): + title = self.title.replace(' ', '.')[0:20] + return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.key, title) + + def exists(self): + try: + self.server.query('/playlists/{0}'.format(self.ratingKey)) + return True + except exceptions.BadRequest: + return False + + def isMusicOrDirectoryItem(self): + return self.playlistType == 'audio' + + def isVideoOrDirectoryItem(self): + return self.playlistType == 'video' + + def items(self): + if not self._itemsLoaded: + path = '/playlists/{0}/items'.format(self.ratingKey) + self._items = plexobjects.listItems(self.server, path) + self._itemsLoaded = True + + return playlist.BasePlaylist.items(self) + + def extend(self, start=0, size=0): + if not self._items: + self._items = [None] * self.leafCount.asInt() + + args = {} + + if size is not None: + args['X-Plex-Container-Start'] = start + args['X-Plex-Container-Size'] = size + + path = '/playlists/{0}/items'.format(self.ratingKey) + if args: + path += util.joinArgs(args) if '?' not in path else '&' + util.joinArgs(args).lstrip('?') + + items = plexobjects.listItems(self.server, path) + self._items[start:start + len(items)] = items + + self.trigger('items.added') + + return items + + def unshuffledItems(self): + if not self._itemsLoaded: + list(self.items()) + return self._items + + @property + def defaultThumb(self): + return self.composite + + def buildComposite(self, **kwargs): + if kwargs: + params = '?' + '&'.join('{0}={1}'.format(k, v) for k, v in kwargs.items()) + else: + params = '' + + path = self.composite + params + return self.getServer().buildUrl(path, True) + + +class BaseHub(plexobjects.PlexObject): + def reset(self): + self.set('offset', 0) + self.set('size', len(self.items)) + totalSize = self.items[0].container.totalSize.asInt() + if totalSize: # Hubs from a list of hubs don't have this, so it it's not here this is intital and we can leave as is + self.set( + 'more', + (self.items[0].container.offset.asInt() + self.items[0].container.size.asInt() < totalSize) and '1' or '' + ) + + +class Hub(BaseHub): + TYPE = "Hub" + + def init(self, data): + self.items = [] + + container = plexobjects.PlexContainer(data, self.key, self.server, self.key or '') + + if self.type == 'genre': + self.items = [media.Genre(elem, initpath='/hubs', server=self.server, container=container) for elem in data] + elif self.type == 'director': + self.items = [media.Director(elem, initpath='/hubs', server=self.server, container=container) for elem in data] + elif self.type == 'actor': + self.items = [media.Role(elem, initpath='/hubs', server=self.server, container=container) for elem in data] + else: + for elem in data: + try: + self.items.append(plexobjects.buildItem(self.server, elem, '/hubs', container=container, tag_fallback=True)) + except exceptions.UnknownType: + util.DEBUG_LOG('Unkown hub item type({1}): {0}'.format(elem, elem.attrib.get('type'))) + + def __repr__(self): + return '<{0}:{1}>'.format(self.__class__.__name__, self.hubIdentifier) + + def getCleanHubIdentifier(self): + return re.sub(r'\.\d+$', '', re.sub(r'\.\d+$', '', self.hubIdentifier)) + + def reload(self, **kwargs): + """ Reload the data for this object from PlexServer XML. """ + try: + data = self.server.query(self.key, params=kwargs) + except Exception as e: + import traceback + traceback.print_exc() + util.ERROR(err=e) + self.initpath = self.key + return + + self.initpath = self.key + self._setData(data) + self.init(data) + + def extend(self, start=None, size=None): + path = self.key + + args = {} + + if size is not None: + args['X-Plex-Container-Start'] = start + args['X-Plex-Container-Size'] = size + + if args: + path += util.joinArgs(args) if '?' not in path else '&' + util.joinArgs(args).lstrip('?') + + items = plexobjects.listItems(self.server, path) + self.offset = plexobjects.PlexValue(start) + self.size = plexobjects.PlexValue(len(items)) + self.more = plexobjects.PlexValue( + (items[0].container.offset.asInt() + items[0].container.size.asInt() < items[0].container.totalSize.asInt()) and '1' or '' + ) + return items + + +class PlaylistHub(BaseHub): + TYPE = "Hub" + type = None + hubIdentifier = None + + def init(self, data): + try: + self.items = self.extend(0, 10) + except exceptions.BadRequest: + util.DEBUG_LOG('AudioPlaylistHub: Bad request: {0}'.format(self)) + self.items = [] + + def getCleanHubIdentifier(self): + return re.sub(r'\.\d+$', '', re.sub(r'\.\d+$', '', self.hubIdentifier)) + + def extend(self, start=None, size=None): + path = '/playlists/all?playlistType={0}'.format(self.type) + + args = {"includeMarkers": 1} + + if size is not None: + args['X-Plex-Container-Start'] = start + args['X-Plex-Container-Size'] = size + else: + start = 0 + + if args: + path += '&' + util.joinArgs(args).lstrip('?') + + items = plexobjects.listItems(self.server, path) + + if not items: + return + + self.set('offset', start) + self.set('size', len(items)) + self.set('more', (items[0].container.offset.asInt() + items[0].container.size.asInt() < items[0].container.totalSize.asInt()) and '1' or '') + return items + + +class AudioPlaylistHub(PlaylistHub): + type = 'audio' + hubIdentifier = 'playlists.audio' + + +class VideoPlaylistHub(PlaylistHub): + type = 'video' + hubIdentifier = 'playlists.video' + + +SECTION_TYPES = { + MovieSection.TYPE: MovieSection, + ShowSection.TYPE: ShowSection, + MusicSection.TYPE: MusicSection, + PhotoSection.TYPE: PhotoSection +} + +SECTION_IDS = { + MovieSection.ID: MovieSection, + ShowSection.ID: ShowSection, + MusicSection.ID: MusicSection, + PhotoSection.ID: PhotoSection +} diff --git a/script.plexmod/lib/_included_packages/plexnet/plexmedia.py b/script.plexmod/lib/_included_packages/plexnet/plexmedia.py new file mode 100644 index 000000000..bbbc51825 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexmedia.py @@ -0,0 +1,154 @@ +from __future__ import absolute_import +from . import locks +from . import http +from . import plexobjects +from . import plexpart +from . import plexrequest +from . import util +from six.moves import filter +import six + + +class PlexMedia(plexobjects.PlexObject): + def __init__(self, data, initpath=None, server=None, container=None): + self._data = data.attrib + plexobjects.PlexObject.__init__(self, data, initpath, server) + self.container_ = self.get('container') + self.container = container + self.indirectHeaders = None + self.parts = [] + # If we weren't given any data, this is a synthetic media + if data is not None: + self.parts = [plexpart.PlexPart(elem, initpath=self.initpath, server=self.server, media=self) for elem in data] + + def get(self, key, default=None): + return self._data.get(key, default) + + def hasStreams(self): + return len(self.parts) > 0 and self.parts[0].hasStreams() + + def isIndirect(self): + return self.get('indirect') == '1' + + def isAccessible(self): + return any(p.isAccessible() for p in self.parts) + + def isAvailable(self): + return any(p.isAvailable() for p in self.parts) + + def resolveIndirect(self): + if not self.isIndirect() or locks.LOCKS.isLocked("resolve_indirect"): + return self + + part = self.parts[0] + if part is None: + util.DEBUG("Failed to resolve indirect media: missing valid part") + return None + + postBody = None + postUrl = part.postURL + request = plexrequest.PlexRequest(self.getServer(), part.key, postUrl is not None and "POST" or "GET") + + if postUrl is not None: + util.DEBUG("Fetching content for indirect media POST URL: {0}".format(postUrl)) + # Force setting the certificate to handle following https redirects + postRequest = http.HttpRequest(postUrl, None, True) + postResponse = postRequest.getToStringWithTimeout(30) + if len(postResponse) > 0 and type(postRequest.event) == "roUrlEvent": + util.DEBUG("Retrieved data from postURL, posting to resolve container") + crlf = chr(13) + chr(10) + postBody = "" + for header in postRequest.event.getResponseHeadersArray(): + for name in header: + postBody = postBody + name + ": " + header[name] + crlf + postBody = postBody + crlf + postResponse + else: + util.DEBUG("Failed to resolve indirect media postUrl") + self.Set("indirect", "-1") + return self + + request.addParam("postURL", postUrl) + + response = request.doRequestWithTimeout(30, postBody) + + item = response.items[0] + if item is None or item.mediaItems[0] is None: + util.DEBUG("Failed to resolve indirect media: no media items") + self.indirect = -1 + return self + + media = item.mediaItems[0] + + # Add indirect headers to the media item + media.indirectHeaders = util.AttributeDict() + for header in (item.container.httpHeaders or '').split("&"): + arr = header.split("=") + if len(arr) == 2: + media.indirectHeaders[arr[0]] = arr[1] + + # Reset the fallback media id if applicable + if self.id.asInt() < 0: + media.id = self.id + + return media.resolveIndirect() + + def __str__(self): + extra = [] + attrs = ("videoCodec", "audioCodec", "audioChannels", "protocol", "id") + if self.get('container'): + extra.append("container={0}".format(self.get('container'))) + + for astr in attrs: + if hasattr(self, astr): + attr = getattr(self, astr) + if attr and not attr.NA: + extra.append("{0}={1}".format(astr, attr)) + + return self.versionString(log_safe=True) + " " + ' '.join(extra) + + def versionString(self, log_safe=False): + details = [] + details.append(self.getVideoResolutionString()) + if self.bitrate.asInt() > 0: + details.append(util.bitrateToString(self.bitrate.asInt() * 1000)) + + detailString = ', '.join(details) + return (log_safe and ' * ' or u" \u2022 ").join([_f for _f in [self.title, detailString] if _f]) + + def __eq__(self, other): + if not other: + return False + + if self.__class__ != other.__class__: + return False + + return self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return self.__str__() + + def getVideoResolution(self): + if self.videoResolution: + standardDefinitionHeight = 480 + if str(util.validInt(list(filter(six.text_type.isdigit, self.videoResolution)))) != self.videoResolution: + return self.height.asInt() > standardDefinitionHeight and self.height.asInt() or standardDefinitionHeight + else: + return self.videoResolution.asInt(standardDefinitionHeight) + + return self.height.asInt() + + def getVideoResolutionString(self): + resNumber = util.validInt(list(filter(six.text_type.isdigit, self.videoResolution))) + if resNumber > 0 and str(resNumber) == self.videoResolution: + return self.videoResolution + "p" + + return self.videoResolution.upper() + + def isSelected(self): + from . import plexapp + return self.selected.asBool() or self.id == util.INTERFACE.getPreference("local_mediaId") + + # TODO(schuyler): getParts diff --git a/script.plexmod/lib/_included_packages/plexnet/plexobjects.py b/script.plexmod/lib/_included_packages/plexnet/plexobjects.py new file mode 100644 index 000000000..6d5c01f2d --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexobjects.py @@ -0,0 +1,572 @@ +from __future__ import absolute_import +from datetime import datetime + +from . import exceptions +from . import util +import json +import six +import time + +# Search Types - Plex uses these to filter specific media types when searching. +SEARCHTYPES = { + 'movie': 1, + 'show': 2, + 'season': 3, + 'episode': 4, + 'artist': 8, + 'album': 9, + 'track': 10 +} + +LIBRARY_TYPES = {} + + +def registerLibType(cls): + LIBRARY_TYPES[cls.TYPE] = cls + return cls + + +def registerLibFactory(ftype): + def wrap(func): + LIBRARY_TYPES[ftype] = func + return func + return wrap + + +class PlexValue(six.text_type): + def __new__(cls, value, parent=None): + self = super(PlexValue, cls).__new__(cls, value) + self.parent = parent + self.NA = False + return self + + def __call__(self, default): + return not self.NA and self or PlexValue(default, self.parent) + + def asBool(self): + return self == '1' + + def asInt(self, default=0): + return int(self or default) + + def asFloat(self, default=0): + return float(self or default) + + def asDatetime(self, format_=None): + if not self: + return None + + if self.isdigit(): + dt = datetime.fromtimestamp(int(self)) + else: + # dt = datetime.strptime(self, '%Y-%m-%d') + # Avoid datetime.strptime to avoid + # https://github.com/python/cpython/issues/71587 + try: + dt = datetime.fromtimestamp(time.mktime(time.strptime(self, '%Y-%m-%d'))) + except OverflowError: + # special case for dates before 1970-01-02 (yes, there are shows that old), mktime fails on those + year, month, day = (int(p) for p in str(self).split("-")) + dt = datetime(year=year, month=month, day=day) + + if not format_: + return dt + + return dt.strftime(format_) + + def asURL(self, includeToken=False): + return self.parent.server.buildUrl(self, includeToken) + + def asTranscodedImageURL(self, w, h, **extras): + return self.parent.server.getImageTranscodeURL(self, w, h, **extras) + + +class JEncoder(json.JSONEncoder): + def default(self, o): + try: + return json.JSONEncoder.default(self, o) + except: + return None + + +def asFullObject(func): + def wrap(self, *args, **kwargs): + if not self.isFullObject(): + self.reload() + return func(self, *args, **kwargs) + + return wrap + + +class Checks(object): + def isLibraryItem(self): + return "/library/metadata" in self.get('key', '') or ("/playlists/" in self.get('key', '') and self.get("type", "") == "playlist") + + def isVideoItem(self): + return False + + def isMusicItem(self): + return False + + def isOnlineItem(self): + return self.isChannelItem() or self.isMyPlexItem() or self.isVevoItem() or self.isIvaItem() + + def isMyPlexItem(self): + return self.container.server.TYPE == 'MYPLEXSERVER' or self.container.identifier == 'com.plexapp.plugins.myplex' + + def isChannelItem(self): + identifier = self.getIdentifier() or "com.plexapp.plugins.library" + return not self.isLibraryItem() and not self.isMyPlexItem() and identifier != "com.plexapp.plugins.library" + + def isVevoItem(self): + return 'vevo://' in self.get('guid') + + def isIvaItem(self): + return 'iva://' in self.get('guid') + + def isGracenoteCollection(self): + return False + + def isIPhoto(self): + return (self.title == "iPhoto" or self.container.title == "iPhoto" or (self.mediaType == "Image" or self.mediaType == "Movie")) + + def isDirectory(self): + return self.name == "Directory" or self.name == "Playlist" + + def isPhotoOrDirectoryItem(self): + return self.type == "photoalbum" # or self.isPhotoItem() + + def isMusicOrDirectoryItem(self): + return self.type in ('artist', 'album', 'track') + + def isVideoOrDirectoryItem(self): + return self.type in ('movie', 'show', 'episode') + + def isSettings(self): + return False + + +class PlexObject(Checks): + def __init__(self, data, initpath=None, server=None, container=None): + self.initpath = initpath + self.key = None + self.server = server + self.container = container + self.mediaChoice = None + self.titleSort = PlexValue('') + self.deleted = False + self._reloaded = False + self.data = data + + if data is None: + return + + self._setData(data) + + self.init(data) + + def _setData(self, data): + if data is False: + return + + self.name = data.tag + for k, v in data.attrib.items(): + if k in ("container",): + k = "attrib_%s" % k + + setattr(self, k, PlexValue(v, self)) + + def __getattr__(self, attr): + a = PlexValue('', self) + a.NA = True + + try: + setattr(self, attr, a) + except AttributeError: + util.LOG('Failed to set attribute: {0} ({1})'.format(attr, self.__class__)) + + return a + + def exists(self): + # Used for media items - for others we just return True + return True + + def get(self, attr, default=''): + ret = self.__dict__.get(attr) + return ret is not None and ret or PlexValue(default, self) + + def set(self, attr, value): + setattr(self, attr, PlexValue(six.text_type(value), self)) + + def init(self, data): + pass + + def isFullObject(self): + return self.initpath is None or self.key is None or self.initpath == self.key + + def getAddress(self): + return self.server.activeConnection.address + + @property + def defaultTitle(self): + return self.get('title') + + @property + def defaultThumb(self): + return self.__dict__.get('thumb') and self.thumb or PlexValue('', self) + + @property + def defaultArt(self): + return self.__dict__.get('art') and self.art or PlexValue('', self) + + def refresh(self): + import requests + self.server.query('%s/refresh' % self.key, method=requests.put) + + def reload(self, _soft=False, **kwargs): + """ Reload the data for this object from PlexServer XML. """ + if _soft and self._reloaded: + return self + + kwargs["includeMarkers"] = 1 + + try: + if self.get('ratingKey'): + data = self.server.query('/library/metadata/{0}'.format(self.ratingKey), params=kwargs) + else: + data = self.server.query(self.key, params=kwargs) + self._reloaded = True + except Exception as e: + import traceback + traceback.print_exc() + util.ERROR(err=e) + self.initpath = self.key + return self + + self.initpath = self.key + + try: + self._setData(data[0]) + except (IndexError, TypeError, AttributeError): + util.DEBUG_LOG('No data on reload: {0}'.format(self)) + return self + + return self + + def softReload(self, **kwargs): + return self.reload(_soft=True, **kwargs) + + def getLibrarySectionId(self): + ID = self.get('librarySectionID') + + if not ID: + ID = self.container.get("librarySectionID", '') + + return ID + + def getLibrarySectionTitle(self): + title = self.get('librarySectionTitle') + + if not title: + title = self.container.get("librarySectionTitle", '') + + if not title: + lsid = self.getLibrarySectionId() + if lsid: + data = self.server.query('/library/sections/{0}'.format(lsid)) + title = data.attrib.get('title1') + if title: + self.librarySectionTitle = title + return str(title) + + def getLibrarySectionType(self): + type_ = self.get('librarySectionType') + + if not type_: + type_ = self.container.get("librarySectionType", '') + + if not type_: + lsid = self.getLibrarySectionId() + if lsid: + data = self.server.query('/library/sections/{0}'.format(lsid)) + type_ = data.attrib.get('type') + if type_: + self.librarySectionTitle = type_ + return type_ + + def getLibrarySectionUuid(self): + uuid = self.get("uuid") or self.get("librarySectionUUID") + + if not uuid: + uuid = self.container.get("librarySectionUUID", "") + + return uuid + + def _findLocation(self, data): + elem = data.find('Location') + if elem is not None: + return elem.attrib.get('path') + return None + + def _findPlayer(self, data): + elem = data.find('Player') + if elem is not None: + return PlexObject(elem, server=self.server) + return None + + def _findTranscodeSession(self, data): + elem = data.find('TranscodeSession') + if elem is not None: + from . import media + return media.TranscodeSession(elem, server=self.server) + return None + + def _findBandwidths(self, data): + elem = data.find("Bandwidths") + if elem is not None: + from . import media + return PlexItemList(elem, media.Bandwidth, media.Bandwidth.TYPE, server=self.server) + return [] + + def _findUser(self, data): + elem = data.find('User') + if elem is not None: + return PlexObject(elem, self.initpath) + return None + + def _findSession(self, data): + elem = data.find('Session') + if elem is not None: + return PlexObject(elem, self.initpath, server=self.server) + return None + + def getAbsolutePath(self, attr): + path = getattr(self, attr, None) + if path is None: + return None + else: + return self.container._getAbsolutePath(path) + + def _getAbsolutePath(self, path): + if path.startswith('/'): + return path + elif "://" in path: + return path + else: + return self.getAddress() + "/" + path + + def getParentPath(self, key): + # Some containers have /children on its key while others (such as playlists) use /items + path = self.getAbsolutePath(key) + if path is None: + return "" + + for suffix in ("/children", "/items"): + path = path.replace(suffix, "") + + return path + + def getServer(self): + return self.server + + def getTranscodeServer(self, localServerRequired=False, transcodeType=None): + server = self.server + + # If the server is myPlex, try to use a different PMS for transcoding + from . import myplexserver + from . import plexapp + if server == myplexserver.MyPlexServer: + fallbackServer = plexapp.SERVERMANAGER.getChannelServer() + + if fallbackServer: + server = fallbackServer + elif localServerRequired: + return None + + return server + + @classmethod + def deSerialize(cls, jstring): + from . import plexserver + obj = json.loads(jstring) + server = plexserver.PlexServer.deSerialize(obj['server']) + server.identifier = None + ad = util.AttributeDict() + ad.attrib = obj['obj'] + ad.find = lambda x: None + po = buildItem(server, ad, ad.initpath, container=server) + + return po + + def serialize(self, full=False): + import json + odict = {} + if full: + for k, v in self.__dict__.items(): + if k not in ('server', 'container', 'media', 'initpath', '_data') and v: + odict[k] = v + else: + odict['key'] = self.key + odict['type'] = self.type + + odict['initpath'] = '/none' + obj = {'obj': odict, 'server': self.server.serialize(full=full)} + + return json.dumps(obj, cls=JEncoder) + + +class PlexContainer(PlexObject): + def __init__(self, data, initpath=None, server=None, address=None): + PlexObject.__init__(self, data, initpath, server) + self.setAddress(address) + + def setAddress(self, address): + if address != "/" and address.endswith("/"): + self.address = address[:-1] + else: + self.address = address + + # TODO(schuyler): Do we need to make sure that we only hang onto the path here and not a full URL? + if not self.address.startswith("/") and "node.plexapp.com" not in self.address: + util.FATAL("Container address is not an expected path: {0}".format(address)) + + def getAbsolutePath(self, path): + if path.startswith('/'): + return path + elif "://" in path: + return path + else: + return self.address + "/" + path + + +class PlexServerContainer(PlexContainer): + def __init__(self, data, initpath=None, server=None, address=None): + PlexContainer.__init__(self, data, initpath, server, address) + from . import plexserver + self.resources = [plexserver.PlexServer(elem) for elem in data] + + def __getitem__(self, idx): + return self.resources[idx] + + def __iter__(self): + for i in self.resources: + yield i + + def __len__(self): + return len(self.resources) + + +class PlexItemList(object): + def __init__(self, data, item_cls, tag, server=None, container=None): + self._data = data + self._itemClass = item_cls + self._itemTag = tag + self._server = server + self._container = container + self._items = None + + def __iter__(self): + for i in self.items: + yield i + + def __getitem__(self, idx): + return self.items[idx] + + @property + def items(self): + if self._items is None: + if self._data is not None: + if self._server: + self._items = [self._itemClass(elem, server=self._server, container=self._container) for elem in self._data if elem.tag == self._itemTag] + else: + self._items = [self._itemClass(elem) for elem in self._data if elem.tag == self._itemTag] + else: + self._items = [] + + return self._items + + def __call__(self, *args): + return self.items + + def __len__(self): + return len(self.items) + + def append(self, item): + self.items.append(item) + + +class PlexMediaItemList(PlexItemList): + def __init__(self, data, item_cls, tag, initpath=None, server=None, media=None): + PlexItemList.__init__(self, data, item_cls, tag, server) + self._initpath = initpath + self._media = media + self._items = None + + @property + def items(self): + if self._items is None: + if self._data is not None: + self._items = [self._itemClass(elem, self._initpath, self._server, self._media) for elem in self._data if elem.tag == self._itemTag] + else: + self._items = [] + + return self._items + + +def findItem(server, path, title): + for elem in server.query(path): + if elem.attrib.get('title').lower() == title.lower(): + return buildItem(server, elem, path) + raise exceptions.NotFound('Unable to find item: {0}'.format(title)) + + +def buildItem(server, elem, initpath, bytag=False, container=None, tag_fallback=False): + libtype = elem.tag if bytag else elem.attrib.get('type') + if not libtype and tag_fallback: + libtype = elem.tag + + if libtype in LIBRARY_TYPES: + cls = LIBRARY_TYPES[libtype] + return cls(elem, initpath=initpath, server=server, container=container) + raise exceptions.UnknownType('Unknown library type: {0}'.format(libtype)) + + +class ItemContainer(list): + def __getattr__(self, attr): + return getattr(self.container, attr) + + def init(self, container): + self.container = container + return self + + +def listItems(server, path, libtype=None, watched=None, bytag=False, data=None, container=None, offset=None, + limit=None, tag_fallback=False, **kwargs): + data = data if data is not None else server.query(path, offset=offset, limit=limit, **kwargs) + container = container or PlexContainer(data, path, server, path) + items = ItemContainer().init(container) + + if data: + for elem in data: + if libtype and elem.attrib.get('type') != libtype: + continue + if watched is True and elem.attrib.get('viewCount', 0) == 0: + continue + if watched is False and elem.attrib.get('viewCount', 0) >= 1: + continue + try: + items.append(buildItem(server, elem, path, bytag, container, tag_fallback)) + except exceptions.UnknownType: + pass + + return items + + +def searchType(libtype): + searchtypesstrs = [str(k) for k in SEARCHTYPES.keys()] + if libtype in SEARCHTYPES + searchtypesstrs: + return libtype + stype = SEARCHTYPES.get(libtype.lower()) + if not stype: + raise exceptions.NotFound('Unknown libtype: %s' % libtype) + return stype diff --git a/script.plexmod/lib/_included_packages/plexnet/plexpart.py b/script.plexmod/lib/_included_packages/plexnet/plexpart.py new file mode 100644 index 000000000..7fd0c138c --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexpart.py @@ -0,0 +1,178 @@ +from __future__ import absolute_import +from . import plexobjects +from . import plexstream +from . import plexrequest +from . import util + + +class PlexPart(plexobjects.PlexObject): + def reload(self): + self.initpath = self.key + + def __init__(self, data, initpath=None, server=None, media=None): + plexobjects.PlexObject.__init__(self, data, initpath, server) + self.container_ = self.container + self.container = media + self.streams = [] + + # If we weren't given any data, this is a synthetic part + if data is not None: + self.streams = [plexstream.PlexStream(e, initpath=self.initpath, server=self.server) for e in data if e.tag == 'Stream'] + if self.indexes: + indexKeys = self.indexes('').split(",") + self.indexes = util.AttributeDict() + for indexKey in indexKeys: + self.indexes[indexKey] = True + + def getAddress(self): + address = self.key + + if address != "": + # TODO(schuyler): Do we need to add a token? Or will it be taken care of via header else:where? + address = self.container.getAbsolutePath(address) + + return address + + def isAccessible(self): + # If we haven't fetched accessibility info, assume it's accessible. + return self.accessible.asBool() if self.accessible else True + + def isAvailable(self): + # If we haven't fetched availability info, assume it's available + return not self.exists or self.exists.asBool() + + def getStreamsOfType(self, streamType): + streams = [] + + foundSelected = False + + for stream in self.streams: + if stream.streamType.asInt() == streamType: + streams.append(stream) + + if stream.isSelected(): + foundSelected = True + + # If this is subtitles, add the none option + if streamType == plexstream.PlexStream.TYPE_SUBTITLE: + none = plexstream.NoneStream() + streams.insert(0, none) + none.setSelected(not foundSelected) + + return streams + + # def getSelectedStreamStringOfType(self, streamType): + # default = None + # availableStreams = 0 + # for stream in self.streams: + # if stream.streamType.asInt() == streamType: + # availableStreams = availableStreams + 1 + # if stream.isSelected() or (default is None and streamType != stream.TYPE_SUBTITLE): + # default = stream + + # if default is not None: + # availableStreams = availableStreams - 1 + # title = default.getTitle() + # suffix = "More" + # else: + # title = "None" + # suffix = "Available" + + # if availableStreams > 0 and streamType != stream.TYPE_VIDEO: + # # Indicate available streams to choose from, excluding video + # # streams until the server supports multiple videos streams. + + # return u"{0} : {1} {2}".format(title, availableStreams, suffix) + # else: + # return title + + def getSelectedStreamOfType(self, streamType): + # Video streams, in particular, may not be selected. Pretend like the + # first one was selected. + + default = None + + for stream in self.streams: + if stream.streamType.asInt() == streamType: + if stream.isSelected(): + return stream + elif default is None and streamType != stream.TYPE_SUBTITLE: + default = stream + + return default + + def setSelectedStream(self, streamType, streamId, _async): + if streamType == plexstream.PlexStream.TYPE_AUDIO: + typeString = "audio" + elif streamType == plexstream.PlexStream.TYPE_SUBTITLE: + typeString = "subtitle" + elif streamType == plexstream.PlexStream.TYPE_VIDEO: + typeString = "video" + else: + return None + + path = "/library/parts/{0}?{1}StreamID={2}".format(self.id(''), typeString, streamId) + + if self.getServer().supportsFeature("allPartsStreamSelection"): + path = path + "&allParts=1" + + request = plexrequest.PlexRequest(self.getServer(), path, "PUT") + + if _async: + context = request.createRequestContext("ignored") + from . import plexapp + util.APP.startRequest(request, context, "") + else: + request.postToStringWithTimeout() + + matching = plexstream.NoneStream() + + # Update any affected streams + for stream in self.streams: + if stream.streamType.asInt() == streamType: + if stream.id == streamId: + stream.setSelected(True) + matching = stream + elif stream.isSelected(): + stream.setSelected(False) + + return matching + + def isIndexed(self): + return bool(self.indexes) + + def getIndexUrl(self, indexKey): + path = self.getIndexPath(indexKey) + if path is not None: + return self.container.server.buildUrl(path + "?interval=10000", True) + else: + return None + + def getIndexPath(self, indexKey, interval=None): + if self.indexes is not None and indexKey in self.indexes: + return "/library/parts/{0}/indexes/{1}".format(self.id, indexKey) + else: + return None + + def hasStreams(self): + return bool(self.streams) + + def __str__(self): + return "PlexPart {0} {1}".format(self.id("NaN"), self.key) + + def __eq__(self, other): + if other is None: + return False + + if self.__class__ != other.__class__: + return False + + return self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return self.__str__() + + # TODO(schuyler): getStreams, getIndexThumbUrl diff --git a/script.plexmod/lib/_included_packages/plexnet/plexplayer.py b/script.plexmod/lib/_included_packages/plexnet/plexplayer.py new file mode 100644 index 000000000..e90a01025 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexplayer.py @@ -0,0 +1,934 @@ +from __future__ import absolute_import +import re +from . import util +from . import captions +from . import http +from . import plexrequest +from . import mediadecisionengine +from . import serverdecision +from lib.util import CACHE_SIZE, advancedSettings, KODI_VERSION_MAJOR + +from six.moves import range + +DecisionFailure = serverdecision.DecisionFailure + + +class PlexPlayer(object): + DECISION_ENDPOINT = "/video/:/transcode/universal/decision" + + def __init__(self, item, seekValue=0, forceUpdate=False): + self.decision = None + self.seekValue = seekValue + self.metadata = None + self.init(item, forceUpdate) + + def init(self, item, forceUpdate=False): + self.item = item + self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item, forceUpdate=forceUpdate) + if self.choice: + self.media = self.choice.media + + def terminate(self, code, reason): + util.LOG('TERMINATE PLAYER: ({0}, {1})'.format(code, reason)) + # TODO: Handle this? ---------------------------------------------------------------------------------------------------------- TODO + + @property + def audioChannels(self): + """ + Parse Kodi channel setting into channel count + """ + channelDef = self.item.settings.getGlobal("audioChannels", "2.0") + major, minor = channelDef.split(".") if "." in channelDef else (channelDef, 0) + return int(major) + int(minor) + + def rebuild(self, item, decision=None): + # item.settings = self.item.settings + oldChoice = self.choice + self.init(item, True) + util.LOG("Replacing '{0}' with '{1}' and rebuilding.".format(oldChoice, self.choice)) + self.build() + self.decision = decision + + def build(self, forceTranscode=False): + if self.item.settings.getPreference("playback_directplay", True): + directPlayPref = self.item.settings.getPreference("playback_directplay_force", False) and 'forced' or 'allow' + else: + directPlayPref = 'disabled' + + if forceTranscode or directPlayPref == "disabled" or self.choice.hasBurnedInSubtitles is True: + directPlay = False + else: + directPlay = directPlayPref == "forced" and True or None + + return self._build(directPlay, self.item.settings.getPreference("playback_remux", True)) + + def _build(self, directPlay=None, directStream=True, currentPartIndex=None): + isForced = directPlay is not None + if isForced: + util.LOG(directPlay and "Forced Direct Play" or "Forced Transcode; allowDirectStream={0}".format(directStream)) + + directPlay = False if directPlay is False else self.choice.isDirectPlayable + + server = self.item.getServer() + + # A lot of our content metadata is independent of the direct play decision. + # Add that first. + + obj = util.AttributeDict() + obj.duration = self.media.duration.asInt() + + videoRes = self.media.getVideoResolution() + obj.fullHD = videoRes >= 1080 + obj.streamQualities = (videoRes >= 480 and self.item.settings.getGlobal("IsHD")) and ["HD"] or ["SD"] + + frameRate = self.media.videoFrameRate or "24p" + if frameRate == "24p": + obj.frameRate = 24 + elif frameRate == "NTSC": + obj.frameRate = 30 + + # Add soft subtitle info + if self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_ANY: + obj.subtitleUrl = server.buildUrl(self.choice.subtitleStream.getSubtitlePath(), True) + elif self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_DP: + obj.subtitleConfig = {'TrackName': "mkv/" + str(self.choice.subtitleStream.index.asInt() + 1)} + + # Create one content metadata object for each part and store them as a + # linked list. We probably want a doubly linked list, except that it + # becomes a circular reference nuisance, so we make the current item the + # base object and singly link in each direction from there. + + baseObj = obj + prevObj = None + startOffset = 0 + + startPartIndex = currentPartIndex or 0 + for partIndex in range(startPartIndex, len(self.media.parts)): + isCurrentPart = (currentPartIndex is not None and partIndex == currentPartIndex) + partObj = util.AttributeDict() + partObj.update(baseObj) + + partObj.live = False + partObj.partIndex = partIndex + partObj.startOffset = startOffset + + part = self.media.parts[partIndex] + + partObj.partDuration = part.duration.asInt() + partObj.path = str(part.file) + partObj.size = part.size and int(part.size) or '' + + if part.isIndexed(): + partObj.sdBifPath = part.getIndexPath("sd") + partObj.hdBifPath = part.getIndexPath("hd") + + # We have to evaluate every part before playback. Normally we'd expect + # all parts to be identical, but in reality they can be different. + + if partIndex > 0 and (not isForced and directPlay or not isCurrentPart): + choice = mediadecisionengine.MediaDecisionEngine().evaluateMediaVideo(self.item, self.media, partIndex) + canDirectPlay = (choice.isDirectPlayable is True) + else: + canDirectPlay = directPlay + + if canDirectPlay: + partObj = self.buildDirectPlay(partObj, partIndex) + else: + transcodeServer = self.item.getTranscodeServer(True, "video") + if transcodeServer is None: + return None + partObj = self.buildTranscode(transcodeServer, partObj, partIndex, directStream, isCurrentPart) + + # Set up our linked list references. If we couldn't build an actual + # object: fail fast. Otherwise, see if we're at our start offset + # yet in order to decide if we need to link forwards or backwards. + # We also need to account for parts missing a duration, by verifying + # the prevObj is None or if the startOffset has incremented. + + if partObj is None: + obj = None + break + elif prevObj is None or (startOffset > 0 and int(self.seekValue / 1000) >= startOffset): + obj = partObj + partObj.prevObj = prevObj + elif prevObj is not None: + prevObj.nextPart = partObj + + startOffset = startOffset + int(part.duration.asInt() / 1000) + + prevObj = partObj + + # Only set PlayStart for the initial part, and adjust for the part's offset + if obj is not None: + if obj.live: + # Start the stream at the end. Per Roku, this can be achieved using + # a number higher than the duration. Using the current time should + # ensure it's definitely high enough. + + obj.playStart = util.now() + 1800 + else: + obj.playStart = int(self.seekValue / 1000) - obj.startOffset + + self.metadata = obj + + util.LOG("Constructed video item for playback: {0}".format(dict(obj))) + + return self.metadata + + @property + def startOffset(self): + return self.metadata and self.metadata.startOffset or 0 + + def offsetIsValid(self, offset_seconds): + return self.metadata.startOffset <= offset_seconds < self.metadata.startOffset + (self.metadata.partDuration / 1000) + + def isLiveHls(url=None, headers=None): + # Check to see if this is a live HLS playlist to fix two issues. One is a + # Roku workaround since it doesn't obey the absence of EXT-X-ENDLIST to + # start playback at the END of the playlist. The second is for us to know + # if it's live to modify the functionality and player UI. + + # if IsString(url): + # request = createHttpRequest(url, "GET", true) + # AddRequestHeaders(request.request, headers) + # response = request.GetToStringWithTimeout(10) + + # ' Inspect one of the media playlist streams if this is a master playlist. + # if response.instr("EXT-X-STREAM-INF") > -1 then + # Info("Identify live HLS: inspecting the master playlist") + # mediaUrl = CreateObject("roRegex", "(^https?://.*$)", "m").Match(response)[1] + # if mediaUrl <> invalid then + # request = createHttpRequest(mediaUrl, "GET", true) + # AddRequestHeaders(request.request, headers) + # response = request.GetToStringWithTimeout(10) + # end if + # end if + + # isLiveHls = (response.Trim().Len() > 0 and response.instr("EXT-X-ENDLIST") = -1 and response.instr("EXT-X-STREAM-INF") = -1) + # Info("Identify live HLS: live=" + isLiveHls.toStr()) + # return isLiveHls + + return False + + def getServerDecision(self): + directPlay = not (self.metadata and self.metadata.isTranscoded) + decisionPath = self.getDecisionPath(directPlay) + newDecision = None + + if decisionPath: + server = self.metadata.transcodeServer or self.item.getServer() + request = plexrequest.PlexRequest(server, decisionPath) + response = request.getWithTimeout(10) + + if response.isSuccess() and response.container: + decision = serverdecision.ServerDecision(self, response, self) + + if decision.isSuccess(): + util.LOG("MDE: Server was happy with client's original decision. {0}".format(decision)) + return self + elif decision.isDecision(True): + util.WARN_LOG("MDE: Server was unhappy with client's original decision. {0}".format(decision)) + return decision.getDecision() + else: + util.LOG("MDE: Server was unbiased about the decision. {0}".format(decision)) + + # Check if the server has provided a new media item to use it. If + # there is no item, then we'll continue along as if there was no + # decision made. + newDecision = decision.getDecision(False) + else: + util.WARN_LOG("MDE: Server failed to provide a decision") + else: + util.WARN_LOG("MDE: Server or item does not support decisions") + + return newDecision or self + + def getDecisionPath(self, directPlay=False): + if not self.item or not self.metadata: + return None + + decisionPath = self.metadata.decisionPath + if not decisionPath: + server = self.metadata.transcodeServer or self.item.getServer() + decisionPath = self.buildTranscode(server, util.AttributeDict(), self.metadata.partIndex, True, False).decisionPath + + # Modify the decision params based on the transcode url + if decisionPath: + if directPlay: + decisionPath = decisionPath.replace("directPlay=0", "directPlay=1") + + # Clear all subtitle parameters and add the a valid subtitle type based + # on the video player. This will let the server decide if it can supply + # sidecar subs, burn or embed w/ an optional transcode. + for key in ("subtitles", "advancedSubtitles"): + decisionPath = re.sub('([?&]{0}=)\w+'.format(key), '', decisionPath) + subType = 'sidecar' # AppSettings().getBoolPreference("custom_video_player"), "embedded", "sidecar") + decisionPath = http.addUrlParam(decisionPath, "subtitles=" + subType) + + # Global variables for all decisions + # Kodi default is 20971520 (20MB) + decisionPath = http.addUrlParam(decisionPath, + "mediaBufferSize={}".format(str(CACHE_SIZE * 1024))) + decisionPath = http.addUrlParam(decisionPath, "hasMDE=1") + + if not advancedSettings.oldprofile: + decisionPath = http.addUrlParam(decisionPath, 'X-Plex-Client-Profile-Name=Generic') + else: + decisionPath = http.addUrlParam(decisionPath, 'X-Plex-Client-Profile-Name=Chrome') + + return decisionPath + + def getTranscodeReason(self): + # Combine the server and local MDE decisions + obj = [] + if self.decision: + obj.append(self.decision.getDecisionText()) + if self.item: + obj.append(self.item.transcodeReason) + reason = ' '.join(obj) + if not reason: + return None + + return reason + + def buildTranscodeHls(self, obj): + util.DEBUG_LOG('buildTranscodeHls()') + obj.streamFormat = "hls" + obj.streamBitrates = [0] + obj.switchingStrategy = "no-adaptation" + obj.transcodeEndpoint = "/video/:/transcode/universal/start.m3u8" + + builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True)) + builder.extras = [] + builder.addParam("protocol", "hls") + + # TODO: This should be Generic, but will need to re-evaluate the augmentations with that change + if not advancedSettings.oldprofile: + builder.addParam("X-Plex-Client-Profile-Name", "Generic") + else: + builder.addParam("X-Plex-Client-Profile-Name", "Chrome") + + if self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_ANY: + builder.addParam("skipSubtitles", "1") + else: # elif self.choice.hasBurnedInSubtitles is True: # Must burn transcoded because we can't set offset + captionSize = captions.CAPTIONS.getBurnedSize() + if captionSize is not None: + builder.addParam("subtitleSize", captionSize) + + # Augment the server's profile for things that depend on the Roku's configuration. + if self.item.settings.supportsAudioStream("ac3", 6): + builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=ac3)") + if not advancedSettings.oldprofile: + builder.extras.append("add-direct-play-profile(type=videoProfile&container=mkv&videoCodec=*&audioCodec=ac3)") + else: + builder.extras.append( + "add-direct-play-profile(type=videoProfile&container=matroska&videoCodec=*&audioCodec=ac3)") + + return builder + + def buildTranscodeMkv(self, obj, directStream=True): + util.DEBUG_LOG('buildTranscodeMkv()') + obj.streamFormat = "mkv" + obj.streamBitrates = [0] + obj.transcodeEndpoint = "/video/:/transcode/universal/start.mkv" + + builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True)) + builder.extras = [] + builder.addParam("protocol", "http") + builder.addParam("copyts", "1") + if not advancedSettings.oldprofile: + builder.addParam("X-Plex-Client-Profile-Name", "Generic") + else: + builder.addParam("X-Plex-Client-Profile-Name", "Chrome") + + obj.subtitleUrl = None + + clampToOrig = self.item.settings.getPreference("audio_clamp_to_orig", True) + useKodiAudio = self.item.settings.getPreference("audio_channels_kodi", False) + AC3Cond = self.item.settings.getPreference("audio_force_ac3_cond", 'never') + dtsIsAC3 = self.item.settings.getPreference("audio_ac3dts", True) + hasAudioChoice = self.choice.audioStream is not None + forceAC3 = AC3Cond != 'never' + + ach = None + if AC3Cond in ('2', '5'): + ach = int(AC3Cond) + + # fixme: still necessary? + if self.choice.subtitleDecision == self.choice.SUBTITLES_BURN: + builder.addParam("subtitles", "burn") + captionSize = captions.CAPTIONS.getBurnedSize() + if captionSize is not None: + builder.addParam("subtitleSize", captionSize) + + else: + # TODO(rob): can we safely assume the id will also be 3 (one based index). + # If not, we will have to get tricky and select the subtitle stream after + # video playback starts via roCaptionRenderer: GetSubtitleTracks() and + # ChangeSubtitleTrack() + + obj.subtitleConfig = {'TrackName': "mkv/3" if hasAudioChoice else "mkv/2"} + + # Allow text conversion of subtitles if we only burn image formats + #if self.item.settings.getPreference("burn_subtitles") == "image": + if not self.item.settings.getPreference("burn_ssa", True): + builder.addParam("advancedSubtitles", "text") + + builder.addParam("subtitles", "auto") + + if not forceAC3: + if directStream: + audioCodecs = "eac3,ac3,dca,aac,mp3,mp2,pcm,flac,alac,wmav2,wmapro,wmavoice,opus,vorbis,truehd" + else: + audioCodecs = "mp3,ac3,dca,aac,opus" + else: + if dtsIsAC3: + audioCodecs = "ac3,dca" + else: + audioCodecs = "ac3" + + subtitleCodecs = "srt,ssa,ass,mov_text,tx3g,ttxt,text,pgs,vobsub,smi,subrip,eia_608_embedded," \ + "eia_708_embedded,dvb_subtitle" + (",webvtt" if KODI_VERSION_MAJOR > 19 else '') + + + util.LOG('MDE-prep: enabling codecs: {}'.format(audioCodecs)) + + # Allow virtually anything in Kodi playback. + + # DP might not do anything here + # builder.extras.append( + # "add-direct-play-profile(type=videoProfile&videoCodec=" + # "h264,mpeg1video,mpeg2video,mpeg4,msmpeg4v2,msmpeg4v3,vc1,wmv3&container=*&" + # "audioCodec="+audioCodecs+"&protocol=http)") + + builder.extras.append( + "add-transcode-target(type=videoProfile&videoCodec=" + "h264,mpeg1video,mpeg2video,mpeg4,msmpeg4v2,msmpeg4v3,vc1,wmv3&container=mkv&" + "audioCodec={}&subtitleCodec={}&protocol=http&context=streaming)".format(audioCodecs, subtitleCodecs)) + + # builder.extras.append( + # "append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=http&audioCodec=" + + # audioCodecs + ")") + + # if self.item.settings.supportsSurroundSound(): + # if self.choice.audioStream is not None: + # numChannels = self.choice.audioStream.channels.asInt(8) + # else: + # numChannels = 8 + # + # for codec in ("ac3", "eac3", "dca"): + # if self.item.settings.supportsAudioStream(codec, numChannels): + # builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=http&audioCodec=" + codec + ")") + # builder.extras.append("add-direct-play-profile(type=videoProfile&videoCodec=*&container=mkv&audioCodec=" + codec + ")") + # if codec == "dca": + # builder.extras.append( + # "add-limitation(scope=videoAudioCodec&scopeName=dca&type=upperBound&name=audio.channels&value=8&isRequired=false)" + # ) + # + # for codec in ("ac3", "eac3", "dca"): + # builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=http&audioCodec=" + codec + ")") + # builder.extras.append("add-direct-play-profile(type=videoProfile&videoCodec=*&container=mkv&audioCodec=" + codec + ")") + + util.LOG('MDE-prep: settings: clampOrig: {}, kodiAudio: {}, forceAC3: {}, dtsIsAC3: {}' + .format(clampToOrig, useKodiAudio, forceAC3, dtsIsAC3)) + + # limit audio channels to original stream's audio channel amount + numChannels = self.choice.audioStream.channels.asInt(8) if hasAudioChoice and \ + self.choice.audioStream.channels else 8 + + # limit OPUS to 334kbit + if numChannels == 8: + # 7.1 + opusBitrate = 334 + elif numChannels >= 6: + # 5.1 + opusBitrate = 256 + else: + # 2 + opusBitrate = 128 + + # limit max audio channels to audio stream or kodi (whichever is lower) + maxAudioChannels = numChannels if not useKodiAudio else min(numChannels, self.audioChannels) + + # if we've got a channel limit for AC3/DTS, apply it + maxAudioChannels = maxAudioChannels if not ach else min(maxAudioChannels, ach) + + if forceAC3 and hasAudioChoice: + # limit max audio channels to the above or 6 for AC3 (whichever is lower) + if self.choice.audioStream.codec != "dca": + maxAudioChannels = min(6, maxAudioChannels) + else: + # allow DTS 6.1 ES + maxAudioChannels = min(7, maxAudioChannels) + + streamWasAC3 = hasAudioChoice and self.choice.audioStream.codec == "ac3" + + if not forceAC3 and hasAudioChoice: + # limit audio bitrate to the same bitrate as the current stream's codec + if clampToOrig and self.choice.audioStream.bitrate: + util.LOG('MDE-prep: limiting {} to {} kbit'.format(self.choice.audioStream.codec.upper(), + self.choice.audioStream.bitrate)) + builder.extras.append( + "add-limitation(scope=videoAudioCodec&scopeName={}&" + "type=upperBound&name=audio.bitrate&value={})".format( + self.choice.audioStream.codec, + self.choice.audioStream.bitrate + ) + ) + + # limit OPUS bitrate + if hasAudioChoice and self.choice.audioStream.codec != "opus": + util.LOG('MDE-prep: limiting OPUS bitrate to {} kbit'.format(opusBitrate)) + builder.extras.append( + "add-limitation(scope=videoAudioCodec&scopeName=opus&type=upperBound&name=audio.bitrate&" + "value={}&isRequired=false)".format(opusBitrate) + ) + + # limit AC3 + if not streamWasAC3 or forceAC3: + util.LOG('MDE-prep: limiting AC3 to 640 kbit') + builder.extras.append( + "add-limitation(scope=videoAudioCodec&scopeName=ac3&type=upperBound&name=audio.bitrate&value=640)" + ) + + util.LOG('MDE-prep: limiting audio channels to {}'.format(maxAudioChannels)) + builder.extras.append( + "add-limitation(scope=videoAudioCodec&scopeName=*&type=upperBound&" + "name=audio.channels&value={})".format(maxAudioChannels) + ) + + # AAC sample rate cannot be less than 22050hz (HLS is capable). + if self.choice.audioStream is not None and self.choice.audioStream.samplingRate.asInt(22050) < 22050: + builder.extras.append( + "add-limitation(scope=videoAudioCodec&scopeName=aac&type=lowerBound&" + "name=audio.samplingRate&value=22050&isRequired=false)") + + # HEVC + if self.item.settings.getPreference("allow_hevc", True): + builder.extras.append( + "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" + "protocol=http&videoCodec=hevc)") + # builder.extras.append( + # "add-direct-play-profile(type=videoProfile&videoCodec=hevc&container=*&audioCodec=*)") + + # VP9 + if self.item.settings.getGlobal("vp9Support"): + builder.extras.append( + "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" + "protocol=http&videoCodec=vp9)") + # builder.extras.append( + # "add-direct-play-profile(type=videoProfile&videoCodec=vp9&container=*&audioCodec=*)") + + # AV1 + if self.item.settings.getPreference("allow_av1", False): + builder.extras.append( + "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" + "protocol=http&videoCodec=av1)") + # builder.extras.append( + # "add-direct-play-profile(type=videoProfile&videoCodec=av1&container=*&audioCodec=*)") + + return builder + + def buildTranscodeMkvLegacy(self, obj, directStream=True): + util.DEBUG_LOG('buildTranscodeMkvLegacy()') + obj.streamFormat = "mkv" + obj.streamBitrates = [0] + obj.transcodeEndpoint = "/video/:/transcode/universal/start.mkv" + + builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True)) + builder.extras = [] + builder.addParam("protocol", "http") + builder.addParam("copyts", "1") + builder.addParam("X-Plex-Client-Profile-Name", "Generic") + + obj.subtitleUrl = None + + # fixme: still necessary? + if True: # if self.choice.subtitleDecision == self.choice.SUBTITLES_BURN: # Must burn transcoded because we can't set offset + builder.addParam("subtitles", "burn") + captionSize = captions.CAPTIONS.getBurnedSize() + if captionSize is not None: + builder.addParam("subtitleSize", captionSize) + + else: + # TODO(rob): can we safely assume the id will also be 3 (one based index). + # If not, we will have to get tricky and select the subtitle stream after + # video playback starts via roCaptionRenderer: GetSubtitleTracks() and + # ChangeSubtitleTrack() + + obj.subtitleConfig = {'TrackName': "mkv/3"} + + # Allow text conversion of subtitles if we only burn image formats + if self.item.settings.getPreference("burn_subtitles") == "image": + builder.addParam("advancedSubtitles", "text") + + builder.addParam("subtitles", "auto") + + if directStream: + audioCodecs = "eac3,ac3,dca,aac,mp3,mp2,pcm,flac,alac,wmav2,wmapro,wmavoice,opus,vorbis,truehd" + else: + audioCodecs = "mp3,ac3,aac,opus" + + # Allow virtually anything in Kodi playback. + + # DP might not do anything here + # builder.extras.append( + # "add-direct-play-profile(type=videoProfile&videoCodec=" + # "h264,mpeg1video,mpeg2video,mpeg4,msmpeg4v2,msmpeg4v3,vc1,wmv3&container=*&" + # "audioCodec="+audioCodecs+"&protocol=http)") + + builder.extras.append( + "add-transcode-target(type=videoProfile&videoCodec=" + "h264,mpeg1video,mpeg2video,mpeg4,msmpeg4v2,msmpeg4v3,vc1,wmv3&container=mkv&" + "audioCodec="+audioCodecs+"&protocol=http&context=streaming)") + + # builder.extras.append( + # "append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=http&audioCodec=" + + # audioCodecs + ")") + + # if self.item.settings.supportsSurroundSound(): + # if self.choice.audioStream is not None: + # numChannels = self.choice.audioStream.channels.asInt(8) + # else: + # numChannels = 8 + # + # for codec in ("ac3", "eac3", "dca"): + # if self.item.settings.supportsAudioStream(codec, numChannels): + # builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=http&audioCodec=" + codec + ")") + # builder.extras.append("add-direct-play-profile(type=videoProfile&videoCodec=*&container=mkv&audioCodec=" + codec + ")") + # if codec == "dca": + # builder.extras.append( + # "add-limitation(scope=videoAudioCodec&scopeName=dca&type=upperBound&name=audio.channels&value=8&isRequired=false)" + # ) + # + # for codec in ("ac3", "eac3", "dca"): + # builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=http&audioCodec=" + codec + ")") + # builder.extras.append("add-direct-play-profile(type=videoProfile&videoCodec=*&container=mkv&audioCodec=" + codec + ")") + + # limit OPUS to 334kbit + numChannels = self.choice.audioStream.channels.asInt(8) if self.choice.audioStream else 8 + + if numChannels == 8: + # 7.1 + opusBitrate = 334 + elif numChannels >= 6: + # 5.1 + opusBitrate = 256 + else: + # 2 + opusBitrate = 128 + + builder.extras.append( + "add-limitation(scope=videoAudioCodec&scopeName=opus&type=upperBound&name=audio.bitrate&" + "value={}&isRequired=false)".format(opusBitrate) + ) + + # limit AC3 + builder.extras.append( + "add-limitation(scope=videoAudioCodec&scopeName=ac3&type=upperBound&name=audio.bitrate&value=640)" + ) + + # limit audio to Kodi audio channels + builder.extras.append( + "add-limitation(scope=videoAudioCodec&scopeName=*&type=upperBound&" + "name=audio.channels&value={})".format(self.audioChannels) + ) + + # AAC sample rate cannot be less than 22050hz (HLS is capable). + if self.choice.audioStream is not None and self.choice.audioStream.samplingRate.asInt(22050) < 22050: + builder.extras.append( + "add-limitation(scope=videoAudioCodec&scopeName=aac&type=lowerBound&" + "name=audio.samplingRate&value=22050&isRequired=false)") + + # HEVC + if self.item.settings.getPreference("allow_hevc", True): + builder.extras.append( + "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" + "protocol=http&videoCodec=hevc)") + # builder.extras.append( + # "add-direct-play-profile(type=videoProfile&videoCodec=hevc&container=*&audioCodec=*)") + + # VP9 + if self.item.settings.getGlobal("vp9Support"): + builder.extras.append( + "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" + "protocol=http&videoCodec=vp9)") + # builder.extras.append( + # "add-direct-play-profile(type=videoProfile&videoCodec=vp9&container=*&audioCodec=*)") + + # AV1 + if self.item.settings.getPreference("allow_av1", False): + builder.extras.append( + "append-transcode-target-codec(type=videoProfile&context=streaming&container=mkv&" + "protocol=http&videoCodec=av1)") + # builder.extras.append( + # "add-direct-play-profile(type=videoProfile&videoCodec=av1&container=*&audioCodec=*)") + + return builder + + def buildDirectPlay(self, obj, partIndex): + util.DEBUG_LOG('buildDirectPlay()') + part = self.media.parts[partIndex] + + server = self.item.getServer() + + # Check if we should include our token or not for this request + obj.isRequestToServer = server.isRequestToServer(server.buildUrl(part.getAbsolutePath("key"))) + obj.streamUrls = [server.buildUrl(part.getAbsolutePath("key"), obj.isRequestToServer)] + obj.token = obj.isRequestToServer and server.getToken() or None + if self.media.protocol == "hls": + obj.streamFormat = "hls" + obj.switchingStrategy = "full-adaptation" + obj.live = self.isLiveHLS(obj.streamUrls[0], self.media.indirectHeaders) + else: + obj.streamFormat = self.media.get('container', 'mp4') + if obj.streamFormat == "mov" or obj.streamFormat == "m4v": + obj.streamFormat = "mp4" + + obj.streamBitrates = [self.media.bitrate.asInt()] + obj.isTranscoded = False + + if self.choice.audioStream is not None: + obj.audioLanguageSelected = self.choice.audioStream.languageCode + + return obj + + def hasMoreParts(self): + return (self.metadata is not None and self.metadata.nextPart is not None) + + def getNextPartOffset(self): + return self.metadata.nextPart.startOffset * 1000 + + def goToNextPart(self): + oldPart = self.metadata + if oldPart is None: + return + + newPart = oldPart.nextPart + if newPart is None: + return + + newPart.prevPart = oldPart + oldPart.nextPart = None + self.metadata = newPart + + util.LOG("Next part set for playback: {0}".format(self.metadata)) + + def getBifUrl(self, offset=0): + server = self.item.getServer() + startOffset = 0 + for part in self.media.parts: + duration = part.duration.asInt() + if startOffset <= offset < startOffset + duration: + bifUrl = part.getIndexPath("hd") or part.getIndexPath("sd") + if bifUrl is not None: + url = server.buildUrl('{0}/{1}'.format(bifUrl, offset - startOffset), True) + return url + + startOffset += duration + + return None + + def buildTranscode(self, server, obj, partIndex, directStream, isCurrentPart): + util.DEBUG_LOG('buildTranscode()') + obj.transcodeServer = server + obj.isTranscoded = True + + # if server.supportsFeature("mkvTranscode") and self.item.settings.getPreference("transcode_format", 'mkv') != "hls": + if server.supportsFeature("mkvTranscode"): + if not advancedSettings.oldprofile: + builder = self.buildTranscodeMkv(obj, directStream=directStream) + else: + builder = self.buildTranscodeMkvLegacy(obj, directStream=directStream) + else: + builder = self.buildTranscodeHls(obj) + + if self.item.getServer().TYPE == 'MYPLEXSERVER': + path = server.swizzleUrl(self.item.getAbsolutePath("key")) + else: + path = self.item.getAbsolutePath("key") + + builder.addParam("path", path) + + part = self.media.parts[partIndex] + seekOffset = int(self.seekValue / 1000) + startOffset = obj.get("startOffset", 0) + + # Disabled for HLS due to a Roku bug plexinc/roku-client-issues#776 + if True: # obj.streamFormat == "mkv": + # Trust our seekOffset for this part if it's the current part (now playing) or + # the seekOffset is within the time frame. We have to trust the current part + # as we may have to rebuild the transcode when seeking, and not all parts + # have a valid duration. + + if isCurrentPart or len(self.media.parts) <= 1 or ( + seekOffset >= startOffset and seekOffset <= startOffset + int(part.duration.asInt() / 1000) + ): + startOffset = seekOffset - startOffset + + # Avoid a perfect storm of PMS and Roku quirks. If we pass an offset to + # the transcoder,: it'll start transcoding from that point. But if + # we try to start a few seconds into the video, the Roku seems to want + # to grab the first segment. The first segment doesn't exist, so PMS + # returns a 404 (but only if the offset is <= 12s, otherwise it returns + # a blank segment). If the Roku gets a 404 for the first segment,: + # it'll fail. So, if we're going to start playing from less than 12 + # seconds, don't bother telling the transcoder. It's not worth the + # potential failure, let it transcode from the start so that the first + # segment will always exist. + + # TODO: Probably can remove this (Rick) + if startOffset <= 12: + startOffset = 0 + else: + startOffset = 0 + + builder.addParam("offset", str(startOffset)) + + builder.addParam("session", self.item.settings.getGlobal("clientIdentifier")) + builder.addParam("directStream", directStream and "1" or "0") + builder.addParam("directPlay", "0") + + qualityIndex = self.item.settings.getQualityIndex(self.item.getQualityType(server)) + builder.addParam("videoQuality", self.item.settings.getGlobal("transcodeVideoQualities")[qualityIndex]) + builder.addParam("videoResolution", str(self.item.settings.getGlobal("transcodeVideoResolutions")[qualityIndex])) + builder.addParam("maxVideoBitrate", self.item.settings.getGlobal("transcodeVideoBitrates")[qualityIndex]) + + if self.media.mediaIndex is not None: + builder.addParam("mediaIndex", str(self.media.mediaIndex)) + + builder.addParam("partIndex", str(partIndex)) + + # Augment the server's profile for things that depend on the Roku's configuration. + if self.item.settings.getPreference("h264_level", "auto") != "auto": + builder.extras.append( + "add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.level&value={0}&isRequired=true)".format( + self.item.settings.getPreference("h264_level") + ) + ) + + if not self.item.settings.getGlobal("supports1080p60") and self.item.settings.getGlobal("transcodeVideoResolutions")[qualityIndex][0] >= 1920: + builder.extras.append("add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.frameRate&value=30&isRequired=false)") + + if builder.extras: + builder.addParam("X-Plex-Client-Profile-Extra", '+'.join(builder.extras)) + + if server.isLocalConnection(): + builder.addParam("location", "lan") + + obj.streamUrls = [builder.getUrl()] + + # Build the decision path now that we have build our stream url, and only if the server supports it. + if server.supportsFeature("streamingBrain"): + decisionPath = builder.getRelativeUrl().replace(obj.transcodeEndpoint, self.DECISION_ENDPOINT) + if decisionPath.startswith(self.DECISION_ENDPOINT): + obj.decisionPath = decisionPath + + return obj + + +class PlexAudioPlayer(object): + def __init__(self, item): + self.containerFormats = { + 'aac': "es.aac-adts" + } + + self.item = item + self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item) + if self.choice: + self.media = self.choice.media + self.lyrics = None # createLyrics(item, self.media) + + def build(self, directPlay=None): + directPlay = directPlay or self.choice.isDirectPlayable + + obj = util.AttributeDict() + + # TODO(schuyler): Do we want/need to add anything generic here? Title? Duration? + + if directPlay: + obj = self.buildDirectPlay(obj) + else: + obj = self.buildTranscode(obj) + + self.metadata = obj + + util.LOG("Constructed audio item for playback: {0}".format(dict(obj))) + + return self.metadata + + def buildTranscode(self, obj): + transcodeServer = self.item.getTranscodeServer(True, "audio") + if not transcodeServer: + return None + + obj.streamFormat = "mp3" + obj.isTranscoded = True + obj.transcodeServer = transcodeServer + obj.transcodeEndpoint = "/music/:/transcode/universal/start.m3u8" + + builder = http.HttpRequest(transcodeServer.buildUrl(obj.transcodeEndpoint, True)) + # builder.addParam("protocol", "http") + builder.addParam("path", self.item.getAbsolutePath("key")) + builder.addParam("session", self.item.getGlobal("clientIdentifier")) + builder.addParam("directPlay", "0") + builder.addParam("directStream", "0") + + obj.url = builder.getUrl() + + return obj + + def buildDirectPlay(self, obj): + if self.choice.part: + obj.url = self.item.getServer().buildUrl(self.choice.part.getAbsolutePath("key"), True) + + # Set and override the stream format if applicable + obj.streamFormat = self.choice.media.get('container', 'mp3') + if self.containerFormats.get(obj.streamFormat): + obj.streamFormat = self.containerFormats[obj.streamFormat] + + # If we're direct playing a FLAC, bitrate can be required, and supposedly + # this is the only way to do it. plexinc/roku-client#48 + # + bitrate = self.choice.media.bitrate.asInt() + if bitrate > 0: + obj.streams = [{'url': obj.url, 'bitrate': bitrate}] + + return obj + + # We may as well fallback to transcoding if we could not direct play + return self.buildTranscode(obj) + + def getLyrics(self): + return self.lyrics + + def hasLyrics(self): + return False + return self.lyrics.isAvailable() + + +class PlexPhotoPlayer(object): + def __init__(self, item): + self.item = item + self.choice = item + self.media = item.media()[0] + self.metadata = None + + def build(self, item=None): + item = item or self.item + media = item.media()[0] + if media.parts and media.parts[0]: + obj = util.AttributeDict() + + part = media.parts[0] + path = part.key or part.thumb + server = item.getServer() + + obj.url = server.buildUrl(path, True) + obj.enableBlur = server.supportsPhotoTranscoding + + util.DEBUG_LOG("Constructed photo item for playback: {0}".format(dict(obj))) + + self.metadata = obj + + return self.metadata diff --git a/script.plexmod/lib/_included_packages/plexnet/plexrequest.py b/script.plexmod/lib/_included_packages/plexnet/plexrequest.py new file mode 100644 index 000000000..764ae1d58 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexrequest.py @@ -0,0 +1,46 @@ +from __future__ import absolute_import +from xml.etree import ElementTree + +from . import plexserver +from . import plexresult +from . import http +from . import util + + +class PlexRequest(http.HttpRequest): + def __init__(self, server, path, method=None): + server = server or plexserver.dummyPlexServer() + + http.HttpRequest.__init__(self, server.buildUrl(path, includeToken=True), method) + + self.server = server + self.path = path + + util.addPlexHeaders(self, server.getToken()) + + def onResponse(self, event, context): + if context.get('completionCallback'): + result = plexresult.PlexResult(self.server, self.path) + result.setResponse(event) + context['completionCallback'](self, result, context) + + def doRequestWithTimeout(self, timeout=10, postBody=None): + # non async request/response + if postBody: + data = ElementTree.fromstring(self.postToStringWithTimeout(postBody, timeout)) + else: + data = ElementTree.fromstring(self.getToStringWithTimeout(timeout)) + + response = plexresult.PlexResult(self.server, self.path) + response.setResponse(self.event) + response.parseFakeXMLResponse(data) + + return response + + +class PlexServerRequest(PlexRequest): + def onResponse(self, event, context): + if context.get('completionCallback'): + result = plexresult.PlexServerResult(self.server, self.path) + result.setResponse(event) + context['completionCallback'](self, result, context) diff --git a/script.plexmod/lib/_included_packages/plexnet/plexresource.py b/script.plexmod/lib/_included_packages/plexnet/plexresource.py new file mode 100644 index 000000000..11dd75f95 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexresource.py @@ -0,0 +1,231 @@ +from __future__ import absolute_import +from xml.etree import ElementTree + +from . import http +from . import exceptions +from . import plexobjects +from . import plexconnection +from . import util + +RESOURCES = 'https://plex.tv/api/resources?includeHttps=1' + + +class PlexResource(object): + def __init__(self, data): + self.connection = None + self.connections = [] + self.accessToken = None + self.sourceType = None + + if data is None: + return + + self.accessToken = data.attrib.get('accessToken') + self.httpsRequired = data.attrib.get('httpsRequired') == '1' + self.type = data.attrib.get('type') + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.product = data.attrib.get('product') + self.provides = data.attrib.get('provides') + self.serverClass = data.attrib.get('serverClass') + self.sourceType = data.attrib.get('sourceType') + self.uuid = self.clientIdentifier + + hasSecureConn = False + + for conn in data.findall('Connection'): + if conn.attrib.get('protocol') == "https": + hasSecureConn = True + break + + addLocalConsFound = [] + for conn in data.findall('Connection'): + connection = plexconnection.PlexConnection( + plexconnection.PlexConnection.SOURCE_MYPLEX, + conn.attrib.get('uri'), + conn.attrib.get('local') == '1', + self.accessToken, + hasSecureConn and conn.attrib.get('protocol') != "https" + ) + + # Keep the secure connection on top + if connection.isSecure and not util.LOCAL_OVER_SECURE: + self.connections.insert(0, connection) + elif not connection.isSecure and util.LOCAL_OVER_SECURE: + self.connections.insert(0, connection) + else: + self.connections.append(connection) + + if connection.isSecureButLocal: + addLocalConsFound.append((connection.isSecureButLocal, connection.address)) + + # If the connection is one of our plex.direct secure connections, add + # the nonsecure variant as well, unless https is required. + # + if self.httpsRequired and conn.attrib.get('protocol') == "https" and conn.attrib.get('address') not in conn.attrib.get('uri'): + self.connections.append( + plexconnection.PlexConnection( + plexconnection.PlexConnection.SOURCE_MYPLEX, + "http://{0}:{1}".format(conn.attrib.get('address'), conn.attrib.get('port')), + conn.attrib.get('local') == '1', + self.accessToken, + True + ) + ) + + # add discovered local cons if necessary + for ipPort, origAddress in addLocalConsFound: + ip, port = ipPort + address = "http://" + ip + ":" + str(port) + for conn in self.connections: + if conn.address == address: + continue + + util.DEBUG_LOG( + "Secure connection {0} has a locally reachable IP, add it to the checklist".format(origAddress)) + lcon = plexconnection.PlexConnection( + plexconnection.PlexConnection.SOURCE_DISCOVERED, + "http://" + ip + ":" + str(port), + True, + self.accessToken, + not util.LOCAL_OVER_SECURE, + skipLocalCheck=True + ) + if util.LOCAL_OVER_SECURE: + self.connections.insert(0, lcon) + else: + self.connections.append(lcon) + + def __repr__(self): + return '<{0}:{1}>'.format(self.__class__.__name__, self.name.encode('utf8')) + + +class ResourceConnection(plexobjects.PlexObject): + # Constants + STATE_UNKNOWN = "unknown" + STATE_UNREACHABLE = "unreachable" + STATE_REACHABLE = "reachable" + STATE_UNAUTHORIZED = "unauthorized" + STATE_INSECURE = "insecure_untested" + + SOURCE_MANUAL = 1 + SOURCE_DISCOVERED = 2 + SOURCE_MYPLEX = 4 + + SCORE_REACHABLE = 4 + SCORE_LOCAL = 2 + SCORE_SECURE = 1 + + def init(self, data): + self.secure = True + self.reachable = False + self.data = None + + def __repr__(self): + return '<{0}:{1}>'.format(self.__class__.__name__, self.uri.encode('utf8')) + + @property + def http_uri(self): + return 'http://{0}:{1}'.format(self.address, self.port) + + @property + def URL(self): + if self.secure: + return self.uri + else: + return self.http_url + + def connect(self): + util.LOG('Connecting: {0}'.format(util.cleanToken(self.URL))) + try: + self.data = self.query('/') + self.reachable = True + return True + except Exception as err: + util.ERROR(util.cleanToken(self.URL), err) + + util.LOG('Connecting: Secure failed, trying insecure...') + self.secure = False + + try: + self.data = self.query('/') + self.reachable = True + return True + except Exception as err: + util.ERROR(util.cleanToken(self.URL), err) + + return False + + def headers(self, token=None): + headers = util.BASE_HEADERS.copy() + if token: + headers['X-Plex-Token'] = token + return headers + + def query(self, path, method=None, token=None, **kwargs): + method = method or http.requests.get + url = self.getURL(path) + util.LOG('{0} {1}'.format(method.__name__.upper(), url)) + response = method(url, headers=self.headers(token), timeout=util.TIMEOUT, **kwargs) + if response.status_code not in (200, 201): + codename = http.status_codes.get(response.status_code)[0] + raise exceptions.BadRequest('({0}) {1}'.format(response.status_code, codename)) + data = response.text.encode('utf8') + + return ElementTree.fromstring(data) if data else None + + def getURL(self, path, token=None): + if token: + delim = '&' if '?' in path else '?' + return '{base}{path}{delim}X-Plex-Token={token}'.format(base=self.URL, path=path, delim=delim, token=util.hideToken(token)) + return '{0}{1}'.format(self.URL, path) + + +class PlexResourceList(plexobjects.PlexItemList): + def __init__(self, data, initpath=None, server=None): + self._data = data + self.initpath = initpath + self._server = server + self._items = None + + @property + def items(self): + if self._items is None: + if self._data is not None: + self._items = [PlexResource(elem, initpath=self.initpath, server=self._server) for elem in self._data] + else: + self._items = [] + + return self._items + + +def fetchResources(token): + headers = util.BASE_HEADERS.copy() + headers['X-Plex-Token'] = token + util.LOG('GET {0}?X-Plex-Token={1}'.format(RESOURCES, util.hideToken(token))) + response = http.GET(RESOURCES) + data = ElementTree.fromstring(response.text.encode('utf8')) + from . import plexserver + return [plexserver.PlexServer(elem) for elem in data] + + +def findResource(resources, search, port=32400): + """ Searches server.name """ + search = search.lower() + util.LOG('Looking for server: {0}'.format(search)) + for server in resources: + if search == server.name.lower(): + util.LOG('Server found: {0}'.format(server)) + return server + util.LOG('Unable to find server: {0}'.format(search)) + raise exceptions.NotFound('Unable to find server: {0}'.format(search)) + + +def findResourceByID(resources, ID): + """ Searches server.clientIdentifier """ + util.LOG('Looking for server by ID: {0}'.format(ID)) + for server in resources: + if ID == server.clientIdentifier: + util.LOG('Server found by ID: {0}'.format(server)) + return server + util.LOG('Unable to find server by ID: {0}'.format(ID)) + raise exceptions.NotFound('Unable to find server by ID: {0}'.format(ID)) diff --git a/script.plexmod/lib/_included_packages/plexnet/plexresult.py b/script.plexmod/lib/_included_packages/plexnet/plexresult.py new file mode 100644 index 000000000..7d65d4f4b --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexresult.py @@ -0,0 +1,102 @@ +from __future__ import absolute_import +from . import http +from . import plexobjects + + +class PlexResult(http.HttpResponse): + def __init__(self, server, address): + self.server = server + self.address = address + self.container = None + self.parsed = None + self.items = [] + + def setResponse(self, event): + self.event = event + + def parseResponse(self): + if self.parsed: + return self.parsed + + self.parsed = False + + if self.isSuccess(): + data = self.getBodyXml() + if data is not None: + self.container = plexobjects.PlexContainer(data, initpath=self.address, server=self.server, address=self.address) + + for node in data: + self.addItem(self.container, node) + + self.parsed = True + + return self.parsed + + def parseFakeXMLResponse(self, data): + if self.parsed: + return self.parsed + + self.parsed = False + + if data is not None: + self.container = plexobjects.PlexContainer(data, initpath=self.address, server=self.server, address=self.address) + + for node in data: + self.addItem(self.container, node) + + self.parsed = True + + return self.parsed + + def addItem(self, container, node): + if node.attrib.get('type') in ('track', 'movie', 'episode', 'photo') and node.tag != 'PlayQueue': + item = plexobjects.buildItem(self.server, node, self.address, container=self.container) + else: + item = plexobjects.PlexObject(node, server=self.container.server, container=self.container) + + # TODO(rob): handle channel settings. We should be able to utilize + # the settings component with some modifications. + if not item.isSettings(): + self.items.append(item) + else: + # Decrement the size and total size if applicable + if self.container.get("size"): + self.container.size = plexobjects.PlexValue(str(self.container.size.asInt() - 1)) + if self.container.get("totalSize"): + self.container.totalSize = plexobjects.PlexValue(str(self.container.totalSize.asInt() - 1)) + + +class PlexServerResult(PlexResult): + def parseResponse(self): + if self.parsed: + return self.parsed + + self.parsed = False + + if self.isSuccess(): + data = self.getBodyXml() + if data is not None: + self.container = plexobjects.PlexServerContainer(data, initpath=self.address, server=self.server, address=self.address) + + for node in data: + self.addItem(self.container, node) + + self.parsed = True + + return self.parsed + + def parseFakeXMLResponse(self, data): + if self.parsed: + return self.parsed + + self.parsed = False + + if data is not None: + self.container = plexobjects.PlexServerContainer(data, initpath=self.address, server=self.server, address=self.address) + + for node in data: + self.addItem(self.container, node) + + self.parsed = True + + return self.parsed diff --git a/script.plexmod/lib/_included_packages/plexnet/plexserver.py b/script.plexmod/lib/_included_packages/plexnet/plexserver.py new file mode 100644 index 000000000..50bf9a56e --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexserver.py @@ -0,0 +1,697 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +import time +import re +import json +import urllib3.exceptions + +from . import http +from . import util +from . import exceptions +from . import compat +from . import verlib + +from xml.etree import ElementTree +from . import signalsmixin +from . import plexobjects +from . import plexresource +from . import plexlibrary +from . import asyncadapter +from six.moves import range +# from plexapi.client import Client +# from plexapi.playqueue import PlayQueue + + +TOTAL_QUERIES = 0 +DEFAULT_BASEURI = 'http://localhost:32400' + + +class PlexServer(plexresource.PlexResource, signalsmixin.SignalsMixin): + TYPE = 'PLEXSERVER' + + def __init__(self, data=None): + signalsmixin.SignalsMixin.__init__(self) + plexresource.PlexResource.__init__(self, data) + self.accessToken = None + self.multiuser = False + self.isSupported = None + self.hasFallback = False + self.supportsAudioTranscoding = False + self.supportsVideoTranscoding = False + self.supportsPhotoTranscoding = False + self.supportsVideoRemuxOnly = False + self.supportsScrobble = True + self.allowsMediaDeletion = False + self.allowChannelAccess = False + self.activeConnection = None + self.serverClass = None + + self.pendingReachabilityRequests = 0 + self.pendingSecureRequests = 0 + + self.features = {} + self.librariesByUuid = {} + + self.server = self + self.session = http.Session() + + self.owner = None + self.owned = False + self.synced = False + self.sameNetwork = False + self.uuid = None + self.name = None + self.platform = None + self.versionNorm = None + self.rawVersion = None + self.transcodeSupport = False + + if data is None: + return + + self.owner = data.attrib.get('sourceTitle') + self.owned = data.attrib.get('owned') == '1' + self.synced = data.attrib.get('synced') == '1' + self.sameNetwork = data.attrib.get('publicAddressMatches') == '1' + self.uuid = data.attrib.get('clientIdentifier') + self.name = data.attrib.get('name') + self.platform = data.attrib.get('platform') + self.rawVersion = data.attrib.get('productVersion') + self.versionNorm = util.normalizedVersion(self.rawVersion) + self.transcodeSupport = data.attrib.get('transcodeSupport') == '1' + + def __eq__(self, other): + if not other: + return False + if self.__class__ != other.__class__: + + return False + return self.uuid == other.uuid and self.owner == other.owner + + def __ne__(self, other): + return not self.__eq__(other) + + def __str__(self): + return ""\ + .format(repr(self.name), self.owned, self.uuid, self.versionNorm, self.activeConnection) + + def __repr__(self): + return self.__str__() + + def close(self): + self.session.cancel() + + def get(self, attr, default=None): + return default + + @property + def isSecure(self): + if self.activeConnection: + return self.activeConnection.isSecure + + @property + def isLocal(self): + if self.activeConnection: + return self.activeConnection.isLocal + + def getObject(self, key): + data = self.query(key) + return plexobjects.buildItem(self, data[0], key, container=self) + + def hubs(self, section=None, count=None, search_query=None): + hubs = [] + + params = {"includeMarkers": 1} + if search_query: + q = '/hubs/search' + params['query'] = search_query.lower() + if section: + params['sectionId'] = section + + if count is not None: + params['limit'] = count + else: + q = '/hubs' + if section: + if section == 'playlists': + audio = plexlibrary.AudioPlaylistHub(False, server=self.server) + video = plexlibrary.VideoPlaylistHub(False, server=self.server) + if audio.items: + hubs.append(audio) + if video.items: + hubs.append(video) + return hubs + else: + q = '/hubs/sections/%s' % section + + if count is not None: + params['count'] = count + + data = self.query(q, params=params) + container = plexobjects.PlexContainer(data, initpath=q, server=self, address=q) + + for elem in data: + hubs.append(plexlibrary.Hub(elem, server=self, container=container)) + return hubs + + def playlists(self, start=0, size=10, hub=None): + try: + return plexobjects.listItems(self, '/playlists/all') + except exceptions.BadRequest: + return None + + @property + def library(self): + if self.platform == 'cloudsync': + return plexlibrary.Library(None, server=self) + else: + return plexlibrary.Library(self.query('/library/'), server=self) + + @property + def sessions(self): + if self.owned: + return plexobjects.listItems(self, '/status/sessions') + raise exceptions.ServerNotOwned + + def findVideoSession(self, client_id, rating_key): + for item in self.sessions: + if item.session and item.session.id == client_id and item.ratingKey == rating_key: + return item + + def buildUrl(self, path, includeToken=False): + if self.activeConnection: + return self.activeConnection.buildUrl(self, path, includeToken) + else: + util.WARN_LOG("Server connection is None, returning an empty url") + return "" + + def query(self, path, method=None, **kwargs): + method = method or self.session.get + + params = kwargs.pop("params", None) + if params: + path += util.joinArgs(params, '?' not in path) + + offset = kwargs.pop("offset", None) + limit = kwargs.pop("limit", None) + if kwargs: + path += util.joinArgs(kwargs, '?' not in path) + kwargs.clear() + + url = self.buildUrl(path, includeToken=True) + + # If URL is empty, try refresh resources and return empty set for now + if not url: + util.WARN_LOG("Empty server url, returning None and refreshing resources") + util.MANAGER.refreshResources(True) + return None + + # add offset/limit + if offset is not None: + url = http.addUrlParam(url, "X-Plex-Container-Start=%s" % offset) + if limit is not None: + url = http.addUrlParam(url, "X-Plex-Container-Size=%s" % limit) + + util.LOG('{0} {1}'.format(method.__name__.upper(), re.sub('X-Plex-Token=[^&]+', 'X-Plex-Token=****', url))) + try: + response = method(url, **kwargs) + if response.status_code not in (200, 201): + codename = http.status_codes.get(response.status_code, ['Unknown'])[0] + raise exceptions.BadRequest('({0}) {1}'.format(response.status_code, codename)) + data = response.text.encode('utf8') + except asyncadapter.TimeoutException: + util.ERROR() + util.MANAGER.refreshResources(True) + return None + except (http.requests.ConnectionError, urllib3.exceptions.ProtocolError): + util.ERROR() + return None + except asyncadapter.CanceledException: + return None + + return ElementTree.fromstring(data) if data else None + + def getImageTranscodeURL(self, path, width, height, **extraOpts): + if not path: + return '' + + eOpts = {"minSize": 1, "upscale": 1} + eOpts.update(extraOpts) + + params = ("&width=%s&height=%s" % (width, height)) + ''.join(["&%s=%s" % (key, eOpts[key]) for key in eOpts]) + + if "://" in path: + imageUrl = self.convertUrlToLoopBack(path) + else: + imageUrl = "http://127.0.0.1:" + self.getLocalServerPort() + path + + path = "/photo/:/transcode?url=" + compat.quote_plus(imageUrl) + params + + # Try to use a better server to transcode for synced servers + if self.synced: + from . import plexservermanager + selectedServer = plexservermanager.MANAGER.getTranscodeServer("photo") + if selectedServer: + return selectedServer.buildUrl(path, True) + + if self.activeConnection: + return self.activeConnection.simpleBuildUrl(self, path) + else: + util.WARN_LOG("Server connection is None, returning an empty url") + return "" + + def isReachable(self, onlySupported=True): + if onlySupported and not self.isSupported: + return False + + return self.activeConnection and self.activeConnection.state == plexresource.ResourceConnection.STATE_REACHABLE + + def isLocalConnection(self): + return self.activeConnection and (self.sameNetwork or self.activeConnection.isLocal) + + def isRequestToServer(self, url): + if not self.activeConnection: + return False + + if ':' in self.activeConnection.address[8:]: + schemeAndHost = self.activeConnection.address.rsplit(':', 1)[0] + else: + schemeAndHost = self.activeConnection.address + + return url.startswith(schemeAndHost) + + def getToken(self): + # It's dangerous to use for each here, because it may reset the index + # on self.connections when something else was in the middle of an iteration. + + for i in range(len(self.connections)): + try: + conn = self.connections[i] + except IndexError: + continue + if conn.token: + return conn.token + + return None + + def getLocalServerPort(self): + # TODO(schuyler): The correct thing to do here is to iterate over local + # connections and pull out the port. For now, we're always returning 32400. + + return '32400' + + def collectDataFromRoot(self, data): + # Make sure we're processing data for our server, and not some other + # server that happened to be at the same IP. + if self.uuid != data.attrib.get('machineIdentifier'): + util.LOG("Got a reachability response, but from a different server") + return False + + self.serverClass = data.attrib.get('serverClass') + self.supportsAudioTranscoding = data.attrib.get('transcoderAudio') == '1' + self.supportsVideoTranscoding = data.attrib.get('transcoderVideo') == '1' or data.attrib.get('transcoderVideoQualities') + self.supportsVideoRemuxOnly = data.attrib.get('transcoderVideoRemuxOnly') == '1' + self.supportsPhotoTranscoding = data.attrib.get('transcoderPhoto') == '1' or ( + not data.attrib.get('transcoderPhoto') and not self.synced and not self.isSecondary() + ) + self.allowChannelAccess = data.attrib.get('allowChannelAccess') == '1' or ( + not data.attrib.get('allowChannelAccess') and self.owned and not self.synced and not self.isSecondary() + ) + self.supportsScrobble = not self.isSecondary() or self.synced + self.allowsMediaDeletion = not self.synced and self.owned and data.attrib.get('allowMediaDeletion') == '1' + self.multiuser = data.attrib.get('multiuser') == '1' + self.name = data.attrib.get('friendlyName') or self.name + self.platform = data.attrib.get('platform') + + # TODO(schuyler): Process transcoder qualities + + self.rawVersion = data.attrib.get('version') + if self.rawVersion: + self.versionNorm = util.normalizedVersion(self.rawVersion) + + features = { + 'mkvTranscode': '0.9.11.11', + 'themeTranscode': '0.9.14.0', + 'allPartsStreamSelection': '0.9.12.5', + 'claimServer': '0.9.14.2', + 'streamingBrain': '1.2.0' + } + + for f, v in features.items(): + if util.normalizedVersion(v) <= self.versionNorm: + self.features[f] = True + + appMinVer = util.INTERFACE.getGlobal('minServerVersionArr', '0.0.0.0') + self.isSupported = self.isSecondary() or util.normalizedVersion(appMinVer) <= self.versionNorm + + util.DEBUG_LOG("Server information updated from reachability check: {0}".format(self)) + + return True + + def updateReachability(self, force=True, allowFallback=False): + if not force and self.activeConnection and self.activeConnection.state != plexresource.ResourceConnection.STATE_UNKNOWN: + return + + util.LOG('Updating reachability for {0}: conns={1}, allowFallback={2}'.format(repr(self.name), len(self.connections), allowFallback)) + + epoch = time.time() + retrySeconds = 60 + minSeconds = 10 + for i in range(len(self.connections)): + conn = self.connections[i] + diff = epoch - (conn.lastTestedAt or 0) + if conn.hasPendingRequest: + util.DEBUG_LOG("Skip reachability test for {0} (has pending request)".format(conn)) + elif (diff < minSeconds or (not self.isSecondary() and self.isReachable() and diff < retrySeconds)) and \ + not conn.state == "unauthorized": + util.DEBUG_LOG("Skip reachability test for {0} (checked {1} secs ago)".format(conn, diff)) + elif conn.testReachability(self, allowFallback): + self.pendingReachabilityRequests += 1 + if conn.isSecure: + self.pendingSecureRequests += 1 + + if self.pendingReachabilityRequests == 1: + self.trigger("started:reachability") + + if self.pendingReachabilityRequests <= 0: + self.trigger("completed:reachability") + + def cancelReachability(self): + for i in range(len(self.connections)): + conn = self.connections[i] + conn.cancelReachability() + + def onReachabilityResult(self, connection): + connection.lastTestedAt = time.time() + connection.hasPendingRequest = None + self.pendingReachabilityRequests -= 1 + if connection.isSecure: + self.pendingSecureRequests -= 1 + + util.DEBUG_LOG("Reachability result for {0}: {1} is {2}".format(repr(self.name), connection.address, connection.state)) + + # Noneate active connection if the state is unreachable + if self.activeConnection and self.activeConnection.state != plexresource.ResourceConnection.STATE_REACHABLE: + self.activeConnection = None + + # Pick a best connection. If we already had an active connection and + # it's still reachable, stick with it. (replace with local if + # available) + best = self.activeConnection + for i in range(len(self.connections) - 1, -1, -1): + try: + conn = self.connections[i] + except IndexError: + continue + + util.DEBUG_LOG("Connection score: {0}, {1}".format(conn.address, conn.getScore(True))) + + if not best or conn.getScore() > best.getScore(): + best = conn + + if best and best.state == best.STATE_REACHABLE: + if (best.isSecure or util.LOCAL_OVER_SECURE) or self.pendingSecureRequests <= 0: + util.DEBUG_LOG("Using connection for {0} for now: {1}".format(repr(self.name), best.address)) + self.activeConnection = best + else: + util.DEBUG_LOG("Found a good connection for {0}, but holding out for better".format(repr(self.name))) + + if self.pendingReachabilityRequests <= 0: + # Retest the server with fallback enabled. hasFallback will only + # be True if there are available insecure connections and fallback + # is allowed. + + if self.hasFallback: + self.updateReachability(False, True) + else: + self.trigger("completed:reachability") + + util.LOG("Active connection for {0} is {1}".format(repr(self.name), self.activeConnection)) + + from . import plexservermanager + plexservermanager.MANAGER.updateReachabilityResult(self, bool(self.activeConnection)) + + def markAsRefreshing(self): + for i in range(len(self.connections)): + conn = self.connections[i] + conn.refreshed = False + + def markUpdateFinished(self, source): + # Any connections for the given source which haven't been refreshed should + # be removed. Since removing from a list is hard, we'll make a new list. + toKeep = [] + hasSecureConn = False + + for i in range(len(self.connections)): + try: + conn = self.connections[i] + except IndexError: + util.DEBUG_LOG("Connection lost during iteration") + continue + if not conn.refreshed: + conn.sources = conn.sources & (~source) + + # If we lost our plex.tv connection, don't remember the token. + if source == conn.SOURCE_MYPLEX: + conn.token = None + + if conn.sources: + if conn.address[:5] == "https": + hasSecureConn = True + toKeep.append(conn) + else: + util.DEBUG_LOG("Removed connection {0} for {1} after updating connections for {2}".format(conn, repr(self.name), source)) + if conn == self.activeConnection: + util.DEBUG_LOG("Active connection lost") + self.activeConnection = None + + # Update fallback flag if our connections have changed + if len(toKeep) != len(self.connections): + for conn in toKeep: + conn.isFallback = hasSecureConn and conn.address[:5] != "https" and not util.LOCAL_OVER_SECURE + + self.connections = toKeep + + return len(self.connections) > 0 + + def merge(self, other): + # Wherever this other server came from, assume its information is better + # except for manual connections. + + if other.sourceType != plexresource.ResourceConnection.SOURCE_MANUAL: + self.name = other.name + self.versionNorm = other.versionNorm + self.sameNetwork = other.sameNetwork + + if other.sourceType == plexresource.ResourceConnection.SOURCE_MANUAL and util.LOCAL_OVER_SECURE: + self.sameNetwork = other.sameNetwork + + # Merge connections + for otherConn in other.connections: + merged = False + for i in range(len(self.connections)): + myConn = self.connections[i] + if myConn == otherConn: + myConn.merge(otherConn) + merged = True + break + + if not merged: + self.connections.append(otherConn) + + # If the other server has a token, then it came from plex.tv, which + # means that its ownership information is better than ours. But if + # it was discovered, then it may incorrectly claim to be owned, so + # we stick with whatever we already had. + + if other.getToken(): + self.owned = other.owned + self.owner = other.owner + + def supportsFeature(self, feature): + return feature in self.features + + def getVersion(self): + if not self.versionNorm: + return '' + + return str(self.versionNorm) + + def convertUrlToLoopBack(self, url): + # If the URL starts with our server URL, replace it with 127.0.0.1:32400. + if self.isRequestToServer(url): + url = 'http://127.0.0.1:32400/' + url.split('://', 1)[-1].split('/', 1)[-1] + return url + + def resetLastTest(self): + for i in range(len(self.connections)): + conn = self.connections[i] + conn.lastTestedAt = None + + def isSecondary(self): + return self.serverClass == "secondary" + + def getLibrarySectionByUuid(self, uuid=None): + if not uuid: + return None + return self.librariesByUuid[uuid] + + def setLibrarySectionByUuid(self, uuid, library): + self.librariesByUuid[uuid] = library + + def hasInsecureConnections(self): + if util.INTERFACE.getPreference('allow_insecure') == 'always': + return False + + # True if we have any insecure connections we have disallowed + for i in range(len(self.connections)): + conn = self.connections[i] + if not conn.isSecure and conn.state == conn.STATE_INSECURE: + return True + + return False + + def hasSecureConnections(self): + for i in range(len(self.connections)): + conn = self.connections[i] + if conn.isSecure: + return True + + return False + + def getLibrarySectionPrefs(self, uuid): + # TODO: Make sure I did this right - ruuk + librarySection = self.getLibrarySectionByUuid(uuid) + + if librarySection and librarySection.key: + # Query and store the prefs only when asked for. We could just return the + # items, but it'll be more useful to store the pref ids in an associative + # array for ease of selecting the pref we need. + + if not librarySection.sectionPrefs: + path = "/library/sections/{0}/prefs".format(librarySection.key) + data = self.query(path) + if data: + librarySection.sectionPrefs = {} + for elem in data: + item = plexobjects.buildItem(self, elem, path) + if item.id: + librarySection.sectionPrefs[item.id] = item + + return librarySection.sectionPrefs + + return None + + def swizzleUrl(self, url, includeToken=False): + m = re.Search("^\w+:\/\/.+?(\/.+)", url) + newUrl = m and m.group(1) or None + return self.buildUrl(newUrl or url, includeToken) + + def hasHubs(self): + return self.platform != 'cloudsync' + + @property + def address(self): + return self.activeConnection.address + + @classmethod + def deSerialize(cls, jstring): + try: + serverObj = json.loads(jstring) + except: + util.ERROR() + util.ERROR_LOG("Failed to deserialize PlexServer JSON") + return + + from . import plexconnection + + server = createPlexServerForName(serverObj['uuid'], serverObj['name']) + server.owned = bool(serverObj.get('owned')) + server.sameNetwork = serverObj.get('sameNetwork') + + hasSecureConn = False + for i in range(len(serverObj.get('connections', []))): + conn = serverObj['connections'][i] + if conn['address'][:5] == "https": + hasSecureConn = True + break + + for i in range(len(serverObj.get('connections', []))): + conn = serverObj['connections'][i] + isFallback = hasSecureConn and conn['address'][:5] != "https" and not util.LOCAL_OVER_SECURE + sources = plexconnection.PlexConnection.SOURCE_BY_VAL[conn['sources']] + connection = plexconnection.PlexConnection(sources, conn['address'], conn['isLocal'], conn['token'], isFallback) + + # Keep the secure connection on top + if connection.isSecure and not util.LOCAL_OVER_SECURE: + server.connections.insert(0, connection) + elif not connection.isSecure and util.LOCAL_OVER_SECURE: + server.connections.insert(0, connection) + else: + server.connections.append(connection) + + if conn.get('active'): + server.activeConnection = connection + + return server + + def serialize(self, full=False): + serverObj = { + 'name': self.name, + 'uuid': self.uuid, + 'owned': self.owned, + 'connections': [] + } + + if full: + for conn in self.connections: + serverObj['connections'].append({ + 'sources': conn.sources, + 'address': conn.address, + 'isLocal': conn.isLocal, + 'isSecure': conn.isSecure, + 'token': conn.token + }) + if conn == self.activeConnection: + serverObj['connections'][-1]['active'] = True + else: + serverObj['connections'] = [{ + 'sources': self.activeConnection.sources, + 'address': self.activeConnection.address, + 'isLocal': self.activeConnection.isLocal, + 'isSecure': self.activeConnection.isSecure, + 'token': self.activeConnection.token or self.getToken(), + 'active': True + }] + + return json.dumps(serverObj) + + +def dummyPlexServer(): + return createPlexServer() + + +def createPlexServer(): + return PlexServer() + + +def createPlexServerForConnection(conn): + obj = createPlexServer() + obj.connections.append(conn) + obj.activeConnection = conn + return obj + + +def createPlexServerForName(uuid, name): + obj = createPlexServer() + obj.uuid = uuid + obj.name = name + return obj + + +def createPlexServerForResource(resource): + # resource.__class__ = PlexServer + # resource.server = resource + # resource.session = http.Session() + return resource diff --git a/script.plexmod/lib/_included_packages/plexnet/plexservermanager.py b/script.plexmod/lib/_included_packages/plexnet/plexservermanager.py new file mode 100644 index 000000000..4738a55ce --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexservermanager.py @@ -0,0 +1,651 @@ +from __future__ import absolute_import +import json + +from . import http +from . import plexconnection +from . import plexresource +from . import plexserver +from . import signalsmixin +from . import callback +from . import plexapp +from . import gdm +from . import util +from six.moves import range + + +class SearchContext(dict): + def __getattr__(self, attr): + return self.get(attr) + + def __setattr__(self, attr, value): + self[attr] = value + + +class PlexServerManager(signalsmixin.SignalsMixin): + def __init__(self): + signalsmixin.SignalsMixin.__init__(self) + # obj.Append(ListenersMixin()) + self.serversByUuid = {} + self.selectedServer = None + self.transcodeServer = None + self.channelServer = None + self.deferReachabilityTimer = None + + self.startSelectedServerSearch() + self.loadState() + + plexapp.util.APP.on("change:user", callback.Callable(self.onAccountChange)) + plexapp.util.APP.on("change:allow_insecure", callback.Callable(self.onSecurityChange)) + plexapp.util.APP.on("change:manual_connections", callback.Callable(self.onManualConnectionChange)) + + def getSelectedServer(self): + return self.selectedServer + + def setSelectedServer(self, server, force=False): + # Don't do anything if the server is already selected. + if self.selectedServer and self.selectedServer == server: + return False + + if server: + # Don't select servers that don't have connections. + if not server.activeConnection: + return False + + # Don't select servers that are not supported + if not server.isSupported: + return False + + if not self.selectedServer or force: + util.LOG("Setting selected server to {0}".format(server)) + self.selectedServer = server + + # Update our saved state. + self.saveState(setPreferred=True) + + # Notify anyone who might care. + util.APP.trigger("change:selectedServer", server=server) + + return True + + return False + + def getServer(self, uuid=None): + if uuid is None: + return None + elif uuid == "myplex": + from . import myplexserver + return myplexserver.MyPlexServer() + else: + return self.serversByUuid[uuid] + + def getServers(self): + servers = [] + for uuid in list(self.serversByUuid.keys()): + if uuid != "myplex": + servers.append(self.serversByUuid[uuid]) + + return servers + + def hasPendingRequests(self): + for server in self.getServers(): + if server.pendingReachabilityRequests: + return True + + return False + + def removeServer(self, server): + del self.serversByUuid[server.uuid] + + self.trigger('remove:server') + + if server == self.selectedServer: + util.LOG("The selected server went away") + self.setSelectedServer(None, force=True) + + if server == self.transcodeServer: + util.LOG("The selected transcode server went away") + self.transcodeServer = None + + if server == self.channelServer: + util.LOG("The selected channel server went away") + self.channelServer = None + + def updateFromConnectionType(self, servers, source): + self.markDevicesAsRefreshing() + + for server in servers: + self.mergeServer(server) + + if self.searchContext and source in self.searchContext.waitingForResources: + #self.searchContext.waitingForResources = False + self.searchContext.waitingForResources.remove(source) + + if not self.searchContext.waitingForResources: + self.deviceRefreshComplete(source) + self.updateReachability(True, True) + self.saveState() + + def updateFromDiscovery(self, server): + merged = self.mergeServer(server) + + if not merged.activeConnection: + merged.updateReachability(False, True) + else: + # self.notifyAboutDevice(merged, True) + pass + + def markDevicesAsRefreshing(self): + for uuid in list(self.serversByUuid.keys()): + self.serversByUuid[uuid].markAsRefreshing() + + def mergeServer(self, server): + if server.uuid in self.serversByUuid: + existing = self.serversByUuid[server.uuid] + existing.merge(server) + util.DEBUG_LOG("Merged {0}".format(repr(server.name))) + return existing + else: + self.serversByUuid[server.uuid] = server + util.DEBUG_LOG("Added new server {0}".format(repr(server.name))) + self.trigger("new:server", server=server) + return server + + def deviceRefreshComplete(self, source): + toRemove = [] + for uuid in list(self.serversByUuid.keys()): + if not self.serversByUuid[uuid].markUpdateFinished(source): + toRemove.append(uuid) + + for uuid in toRemove: + if uuid not in self.serversByUuid: + util.DEBUG_LOG("Server {} lost - removing".format(uuid)) + continue + + server = self.serversByUuid[uuid] + + util.DEBUG_LOG("Server {0} has no more connections - removing".format(repr(server.name))) + # self.notifyAboutDevice(server, False) + self.removeServer(server) + + def updateReachability(self, force=False, preferSearch=False, defer=False): + # We don't need to test any servers unless we are signed in and authenticated. + if not plexapp.ACCOUNT.isAuthenticated and plexapp.ACCOUNT.isActive(): + util.LOG("Ignore testing server reachability until we're authenticated") + return + + # To improve reachability performance and app startup, we'll try to test the + # preferred server first, and defer the connection tests for a few seconds. + + hasPreferredServer = bool(self.searchContext.preferredServer) + preferredServerExists = hasPreferredServer and self.searchContext.preferredServer in self.serversByUuid + + if preferSearch and hasPreferredServer and preferredServerExists: + # Update the preferred server immediately if requested and exits + util.LOG("Updating reachability for preferred server: force={0}".format(force)) + self.serversByUuid[self.searchContext.preferredServer].updateReachability(force) + self.deferUpdateReachability() + elif defer: + self.deferUpdateReachability() + elif hasPreferredServer and not preferredServerExists and gdm.DISCOVERY.isActive(): + # Defer the update if requested or if GDM discovery is enabled and + # active while the preferred server doesn't exist. + + util.LOG("Defer update reachability until GDM has finished to help locate the preferred server") + self.deferUpdateReachability(True, False) + else: + if self.deferReachabilityTimer: + self.deferReachabilityTimer.cancel() + self.deferReachabilityTimer = None + + util.LOG("Updating reachability for all devices: force={0}".format(force)) + for uuid in list(self.serversByUuid.keys()): + self.serversByUuid[uuid].updateReachability(force) + + def cancelReachability(self): + if self.deferReachabilityTimer: + self.deferReachabilityTimer.cancel() + self.deferReachabilityTimer = None + + for uuid in list(self.serversByUuid.keys()): + self.serversByUuid[uuid].cancelReachability() + + def updateReachabilityResult(self, server, reachable=False): + searching = not self.selectedServer and self.searchContext + + if reachable: + # If we're in the middle of a search for our selected server, see if + # this is a candidate. + self.trigger('reachable:server', server=server) + if searching: + # If this is what we were hoping for, select it + if server.uuid == self.searchContext.preferredServer: + self.setSelectedServer(server, True) + elif server.synced: + self.searchContext.fallbackServer = server + elif self.compareServers(self.searchContext.bestServer, server) < 0: + self.searchContext.bestServer = server + else: + # If this is what we were hoping for, see if there are any more pending + # requests to hope for. + + if searching and server.uuid == self.searchContext.preferredServer and server.pendingReachabilityRequests <= 0: + self.searchContext.preferredServer = None + + if server == self.selectedServer: + util.LOG("Selected server is not reachable") + self.setSelectedServer(None, True) + + if server == self.transcodeServer: + util.LOG("The selected transcode server is not reachable") + self.transcodeServer = None + + if server == self.channelServer: + util.LOG("The selected channel server is not reachable") + self.channelServer = None + + # See if we should settle for the best we've found so far. + self.checkSelectedServerSearch() + + def checkSelectedServerSearch(self, skip_preferred=False, skip_owned=False): + if self.selectedServer: + return self.selectedServer + elif self.searchContext: + # If we're still waiting on the resources response then there's no + # reason to settle, so don't even iterate over our servers. + + if self.searchContext.waitingForResources: + util.DEBUG_LOG("Still waiting for plex.tv resources") + return + + waitingForPreferred = False + waitingForOwned = False + waitingForAnything = False + waitingToTestAll = bool(self.deferReachabilityTimer) + + if skip_preferred: + self.searchContext.preferredServer = None + if self.deferReachabilityTimer: + self.deferReachabilityTimer.cancel() + self.deferReachabilityTimer = None + + if not skip_owned: + # Iterate over all our servers and see if we're waiting on any results + servers = self.getServers() + pendingCount = 0 + for server in servers: + if server.pendingReachabilityRequests > 0: + pendingCount += server.pendingReachabilityRequests + if server.uuid == self.searchContext.preferredServer: + waitingForPreferred = True + elif server.owned: + waitingForOwned = True + else: + waitingForAnything = True + + pendingString = "{0} pending reachability tests".format(pendingCount) + + if waitingForPreferred: + util.LOG("Still waiting for preferred server: " + pendingString) + elif waitingToTestAll: + util.LOG("Preferred server not reachable, testing all servers now") + self.updateReachability(True, False, False) + elif waitingForOwned and (not self.searchContext.bestServer or not self.searchContext.bestServer.owned): + util.LOG("Still waiting for an owned server: " + pendingString) + elif waitingForAnything and not self.searchContext.bestServer: + util.LOG("Still waiting for any server: {0}".format(pendingString)) + else: + # No hope for anything better, let's select what we found + util.LOG("Settling for the best server we found") + self.setSelectedServer(self.searchContext.bestServer or self.searchContext.fallbackServer, True) + return self.selectedServer + + def compareServers(self, first, second): + if not first or not first.isSupported: + return second and -1 or 0 + elif not second: + return 1 + elif first.owned != second.owned: + return first.owned and 1 or -1 + elif first.isLocalConnection() != second.isLocalConnection(): + return first.isLocalConnection() and 1 or -1 + else: + return 0 + + def loadState(self): + jstring = util.INTERFACE.getRegistry("PlexServerManager") + if not jstring: + return + + try: + obj = json.loads(jstring) + except: + util.ERROR() + obj = None + + if not obj: + util.ERROR_LOG("Failed to parse PlexServerManager JSON") + return + + for serverObj in obj['servers']: + server = plexserver.createPlexServerForName(serverObj['uuid'], serverObj['name']) + server.owned = bool(serverObj.get('owned')) + server.sameNetwork = serverObj.get('sameNetwork') + + hasSecureConn = False + for i in range(len(serverObj.get('connections', []))): + conn = serverObj['connections'][i] + if conn['address'][:5] == "https": + hasSecureConn = True + break + + for i in range(len(serverObj.get('connections', []))): + conn = serverObj['connections'][i] + isFallback = hasSecureConn and conn['address'][:5] != "https" and not util.LOCAL_OVER_SECURE + sources = plexconnection.PlexConnection.SOURCE_BY_VAL[conn['sources']] + connection = plexconnection.PlexConnection(sources, conn['address'], conn['isLocal'], conn['token'], isFallback) + + # Keep the secure connection on top + if connection.isSecure and not util.LOCAL_OVER_SECURE: + server.connections.insert(0, connection) + elif not connection.isSecure and util.LOCAL_OVER_SECURE: + server.connections.insert(0, connection) + else: + server.connections.append(connection) + + self.serversByUuid[server.uuid] = server + + util.LOG("Loaded {0} servers from registry".format(len(obj['servers']))) + self.updateReachability(False, True) + + def saveState(self, setPreferred=False): + # Serialize our important information to JSON and save it to the registry. + # We'll always update server info upon connecting, so we don't need much + # info here. We do have to use roArray instead of roList, because Brightscript. + + obj = {} + + servers = self.getServers() + obj['servers'] = [] + + for server in servers: + # Don't save secondary servers. They should be discovered through GDM or myPlex. + if not server.isSecondary(): + serverObj = { + 'name': server.name, + 'uuid': server.uuid, + 'owned': server.owned, + 'sameNetwork': server.sameNetwork, + 'connections': [] + } + + for i in range(len(server.connections)): + conn = server.connections[i] + serverObj['connections'].append({ + 'sources': conn.sources, + 'address': conn.address, + 'isLocal': conn.isLocal, + 'isSecure': conn.isSecure, + 'token': conn.token + }) + + obj['servers'].append(serverObj) + + if self.selectedServer and not self.selectedServer.synced and not self.selectedServer.isSecondary() \ + and setPreferred: + util.INTERFACE.setPreference("lastServerId.{}".format(plexapp.ACCOUNT.ID), self.selectedServer.uuid) + + util.INTERFACE.setRegistry("PlexServerManager", json.dumps(obj)) + + def clearState(self): + util.INTERFACE.setRegistry("PlexServerManager", '') + + def isValidForTranscoding(self, server): + return server and server.activeConnection and server.owned and not server.synced and not server.isSecondary() + + def getChannelServer(self): + if not self.channelServer or not self.channelServer.isReachable(): + self.channelServer = None + + # Attempt to find a server that supports channels and transcoding + for s in self.getServers(): + if s.supportsVideoTranscoding and s.allowChannelAccess and s.isReachable() and self.compareServers(self.channelServer, s) < 0: + self.channelServer = s + + # Fallback to any server that supports channels + if not self.channelServer: + for s in self.getServers(): + if s.allowChannelAccess and s.isReachable() and self.compareServers(self.channelServer, s) < 0: + self.channelServer = s + + if self.channelServer: + util.LOG("Setting channel server to {0}".format(self.channelServer)) + + return self.channelServer + + def getTranscodeServer(self, transcodeType=None): + if not self.selectedServer: + return None + + transcodeMap = { + 'audio': "supportsAudioTranscoding", + 'video': "supportsVideoTranscoding", + 'photo': "supportsPhotoTranscoding" + } + transcodeSupport = transcodeMap[transcodeType] + + # Try to use a better transcoding server for synced or secondary servers + if self.selectedServer.synced or self.selectedServer.isSecondary(): + if self.transcodeServer and self.transcodeServer.isReachable(): + return self.transcodeServer + + self.transcodeServer = None + for server in self.getServers(): + if not server.synced and server.isReachable() and self.compareServers(self.transcodeServer, server) < 0: + if not transcodeSupport or server.transcodeSupport: + self.transcodeServer = server + + if self.transcodeServer: + transcodeTypeString = transcodeType or '' + util.LOG("Found a better {0} transcode server than {1}, using: {2}".format(transcodeTypeString, self.selectedserver, self.transcodeServer)) + return self.transcodeServer + + return self.selectedServer + + def startSelectedServerSearch(self, reset=False, ID=None): + if reset: + self.selectedServer = None + self.transcodeServer = None + self.channelServer = None + + ID = ID is not None and ID or plexapp.ACCOUNT.ID + pServ = util.INTERFACE.getPreference("lastServerId.{}".format(ID), '') + util.DEBUG_LOG("Preferred server for {0} is: {1}".format(ID, pServ)) + # Keep track of some information during our search + + waitFor = [] + if plexapp.ACCOUNT.isSignedIn: + waitFor.append(plexresource.ResourceConnection.SOURCE_MYPLEX) + + if util.LOCAL_OVER_SECURE and self.getManualConnections(): + waitFor.append(plexresource.ResourceConnection.SOURCE_MANUAL) + + self.searchContext = SearchContext({ + 'bestServer': None, + 'preferredServer': pServ, + 'waitingForResources': waitFor + }) + + util.LOG("Starting selected server search, hoping for {0}".format(self.searchContext.preferredServer)) + if util.LOCAL_OVER_SECURE: + util.WARN_LOG("Preferring local server connections over secure ones!") + + def onAccountChange(self, account, reallyChanged=False): + # Clear any AudioPlayer data before invalidating the active server + if reallyChanged: + # AudioPlayer().Cleanup() + # PhotoPlayer().Cleanup() + + util.DEBUG_LOG("Account really changed, clearing all servers") + + # Clear selected and transcode servers on user change + self.selectedServer = None + self.transcodeServer = None + self.channelServer = None + self.cancelReachability() + + if account.isSignedIn: + # If the user didn't really change, such as selecting the previous user + # on the lock screen, then we don't need to clear anything. We can + # avoid a costly round of reachability checks. + + if not reallyChanged: + return + + # A request to refresh resources has already been kicked off. We need + # to clear out any connections for the previous user and then start + # our selected server search. + + self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MYPLEX) + self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_DISCOVERED) + self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MANUAL) + + self.startSelectedServerSearch(True, ID=account.ID) + + if reallyChanged: + util.DEBUG_LOG("User really changed, refreshing resources now") + plexapp.refreshResources() + else: + # Clear servers/connections from plex.tv + self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MYPLEX) + + def deferUpdateReachability(self, addTimer=True, logInfo=True): + if addTimer and not self.deferReachabilityTimer: + self.deferReachabilityTimer = plexapp.createTimer(1000, callback.Callable(self.onDeferUpdateReachabilityTimer), repeat=True) + util.APP.addTimer(self.deferReachabilityTimer) + else: + if self.deferReachabilityTimer: + self.deferReachabilityTimer.reset() + + if self.deferReachabilityTimer and logInfo: + util.LOG('Defer update reachability for all devices a few seconds: GDMactive={0}'.format(gdm.DISCOVERY.isActive())) + + def onDeferUpdateReachabilityTimer(self): + if not self.selectedServer and self.searchContext: + for server in self.getServers(): + if server.pendingReachabilityRequests > 0 and server.uuid == self.searchContext.preferredServer: + util.DEBUG_LOG( + 'Still waiting on {0} responses from preferred server: {1}'.format( + server.pendingReachabilityRequests, self.searchContext.preferredServer + ) + ) + return + + if self.deferReachabilityTimer: + self.deferReachabilityTimer.cancel() + self.deferReachabilityTimer = None + self.updateReachability(True, False, False) + + def resetLastTest(self): + for uuid in list(self.serversByUuid.keys()): + self.serversByUuid[uuid].resetLastTest() + + def clearServers(self): + self.cancelReachability() + self.serversByUuid = {} + self.saveState() + + def onSecurityChange(self, value=None): + # If the security policy changes, then we will need to allow all + # connections to be retested by resetting the last test. We can + # simply call `self.resetLastTest()` to allow all connection to be + # tested when the server dropdown is enable, but we may as well + # test all the connections immediately. + + plexapp.refreshResources(True) + + def onManualConnectionChange(self, value=None): + # Clear all manual connections on change. We will keep the selected + # server around temporarily if it's a manual connection regardless + # if it's been removed. + + # Remember the current server in case it's removed + server = self.getSelectedServer() + activeConn = [] + if server and server.activeConnection: + activeConn.append(server.activeConnection) + + # Clear all manual connections + self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MANUAL) + + # Reused the previous selected server if our manual connection has gone away + if not self.getSelectedServer() and activeConn.sources == plexresource.ResourceConnection.SOURCE_MANUAL: + server.activeConnection = activeConn + server.connections.append(activeConn) + self.setSelectedServer(server, True) + + def refreshManualConnections(self): + manualConnections = self.getManualConnections() + if not manualConnections: + util.DEBUG_LOG("No manual connections.") + return + + util.LOG("Refreshing {0} manual connections".format(len(manualConnections))) + + for conn in manualConnections: + # Default to http, as the server will need to be signed in for https to work, + # so the client should too. We'd also have to allow hostname entry, instead of + # IP address for the cert to validate. + + proto = "http" + port = conn.port or "32400" + serverAddress = "{0}://{1}:{2}".format(proto, conn.connection, port) + + request = http.HttpRequest(serverAddress + "/identity") + context = request.createRequestContext("manual_connections", callback.Callable(self.onManualConnectionsResponse)) + context.serverAddress = serverAddress + context.address = conn.connection + context.proto = proto + context.port = port + util.APP.startRequest(request, context) + + def onManualConnectionsResponse(self, request, response, context): + if not response.isSuccess(): + return + + data = response.getBodyXml() + if data is not None: + serverAddress = context.serverAddress + util.DEBUG_LOG("Received manual connection response for {0}".format(serverAddress)) + + machineID = data.attrib.get('machineIdentifier') + name = context.address + if not name or not machineID: + return + + # TODO(rob): Do we NOT want to consider manual connections local? + conn = plexconnection.PlexConnection(plexresource.ResourceConnection.SOURCE_MANUAL, serverAddress, True, None) + server = plexserver.createPlexServerForConnection(conn) + server.uuid = machineID + server.name = name + server.sourceType = plexresource.ResourceConnection.SOURCE_MANUAL + self.updateFromConnectionType([server], plexresource.ResourceConnection.SOURCE_MANUAL) + + def getManualConnections(self): + manualConnections = [] + + jstring = util.INTERFACE.getPreference('manual_connections') + if jstring: + connections = json.loads(jstring) + if isinstance(connections, list): + for conn in connections: + conn = util.AttributeDict(conn) + if conn.connection: + manualConnections.append(conn) + + return manualConnections + +# TODO(schuyler): Notifications +# TODO(schuyler): Transcode (and primary) server selection + + +MANAGER = PlexServerManager() diff --git a/script.plexmod/lib/_included_packages/plexnet/plexstream.py b/script.plexmod/lib/_included_packages/plexnet/plexstream.py new file mode 100644 index 000000000..33b5352f6 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/plexstream.py @@ -0,0 +1,195 @@ +from __future__ import absolute_import +from . import plexobjects +from . import util + + +class PlexStream(plexobjects.PlexObject): + # Constants + TYPE_UNKNOWN = 0 + TYPE_VIDEO = 1 + TYPE_AUDIO = 2 + TYPE_SUBTITLE = 3 + TYPE_LYRICS = 4 + + streamTypeNames = ( + "Unknown", "VideoStream", "AudioStream", "SubtitleStream", "LyricsStream" + ) + + # We have limited font support, so make a very modest effort at using + # English names for common unsupported languages. + + SAFE_LANGUAGE_NAMES = { + 'ara': "Arabic", + 'arm': "Armenian", + 'bel': "Belarusian", + 'ben': "Bengali", + 'bul': "Bulgarian", + 'chi': "Chinese", + 'cze': "Czech", + 'gre': "Greek", + 'heb': "Hebrew", + 'hin': "Hindi", + 'jpn': "Japanese", + 'kor': "Korean", + 'rus': "Russian", + 'srp': "Serbian", + 'tha': "Thai", + 'ukr': "Ukrainian", + 'yid': "Yiddish" + } + + def reload(self): + pass + + def getTitle(self, translate_func=util.dummyTranslate): + streamType = self.streamType.asInt() + + if streamType == self.TYPE_SUBTITLE \ + and util.INTERFACE.getPreference('subtitle_use_extended_title', True) \ + and self.extendedDisplayTitle: + return self.extendedDisplayTitle + + title = self.getLanguageName(translate_func) + + if streamType == self.TYPE_VIDEO: + title = self.getCodec() or translate_func("Unknown") + elif streamType == self.TYPE_AUDIO: + codec = self.getCodec() + channels = self.getChannels(translate_func) + + if codec != "" and channels != "": + title += u" ({0} {1})".format(codec, channels) + elif codec != "" or channels != "": + title += u" ({0}{1})".format(codec, channels) + elif streamType == self.TYPE_SUBTITLE: + extras = [] + + codec = self.getCodec() + if codec: + extras.append(codec) + + if self.sdh: + title += " {}".format(translate_func("SDH")) + + if not self.key: + extras.append(translate_func("Embedded")) + + if self.forced.asBool(): + extras.append(translate_func("Forced")) + + if len(extras) > 0: + title += u" ({0})".format('/'.join(extras)) + elif streamType == self.TYPE_LYRICS: + title = translate_func("Lyrics") + if self.format: + title += u" ({0})".format(self.format) + + return title + + def getCodec(self): + codec = (self.codec or '').lower() + + if codec in ('dca', 'dca-ma', 'dts-hd', 'dts-es', 'dts-hra'): + codec = "DTS" + else: + codec = codec.upper() + + return codec + + def getChannels(self, translate_func=util.dummyTranslate): + channels = self.channels.asInt() + + if channels == 1: + return translate_func("Mono") + elif channels == 2: + return translate_func("Stereo") + elif channels > 0: + return "{0}.1".format(channels - 1) + else: + return "" + + def getLanguageName(self, translate_func=util.dummyTranslate): + code = self.languageCode + + if not code: + return translate_func("Unknown") + + return self.SAFE_LANGUAGE_NAMES.get(code) or self.language or "Unknown" + + def getSubtitlePath(self): + query = "?encoding=utf-8" + + if self.codec == "smi": + query += "&format=srt" + + return self.key + query + + def getSubtitleServerPath(self): + if not self.key: + return None + + return self.getServer().buildUrl(self.getSubtitlePath(), True) + + @property + def embedded(self): + return not bool(self.getSubtitleServerPath()) + + def isSelected(self): + return self.selected.asBool() + + def setSelected(self, selected): + self.selected = plexobjects.PlexValue(selected and '1' or '0') + + @property + def sdh(self): + return self.hearingImpaired or "SDH" in self.title or "SDH" in self.displayTitle \ + or "SDH" in self.extendedDisplayTitle + + @property + def videoCodecRendering(self): + render = "sdr" + + if self.colorTrc == "smpte2084": + if self.DOVIProfile == "8" and self.DOVIBLCompatID == "1": + render = "dv/hdr10" + elif self.DOVIProfile: + render = "dv" + else: + render = "hdr" + elif self.colorTrc == "arib-std-b67": + render = "hlg" + + return render.upper() + + def __str__(self): + return self.getTitle() + + def __repr__(self): + return '<{}: {}>'.format(self.streamTypeNames[self.streamType.asInt()], str(self)) + + def __eq__(self, other): + if not other: + return False + + if self.__class__ != other.__class__: + return False + + for attr in ("streamType", "language", "codec", "channels", "index", "key"): + if getattr(self, attr) != getattr(other, attr): + return False + return True + + +# Synthetic subtitle stream for 'none' + +class NoneStream(PlexStream): + def __init__(self, *args, **kwargs): + PlexStream.__init__(self, None, *args, **kwargs) + self.id = plexobjects.PlexValue("0") + self.streamType = plexobjects.PlexValue(str(self.TYPE_SUBTITLE)) + + def getTitle(self, translate_func=util.dummyTranslate): + return translate_func("None") + + +NONE_STREAM = NoneStream() diff --git a/script.plexmod/lib/_included_packages/plexnet/serverdecision.py b/script.plexmod/lib/_included_packages/plexnet/serverdecision.py new file mode 100644 index 000000000..e1a5344c6 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/serverdecision.py @@ -0,0 +1,102 @@ +from __future__ import absolute_import +from . import mediachoice +from . import util + + +class DecisionFailure(Exception): + def __init__(self, code, reason): + self.code = code + self.reason = reason + + +class ServerDecision(object): + DECISION_DIRECT_PLAY = "directplay" + DECISION_COPY = "copy" + DECISION_TRANSCODE = "transcode" + DIRECT_PLAY_OK = 1000 + TRANSCODE_OK = 1001 + + def __init__(self, original, response, player): + self.original = original + self.response = response + self.player = player + self.item = None + + self.init() + + def init(self): + self.isSupported = self.response.server.supportsFeature("streamingBrain") + for item in self.response.items: + if item and item.media: + self.item = item + self.original.transcodeDecision = mediachoice.MediaChoice(self.item.media[0]) + + # Decision codes and text + self.decisionsCodes = {} + self.decisionsTexts = {} + for key in ["directPlayDecision", "generalDecision", "mdeDecision", "transcodeDecision", "termination"]: + self.decisionsCodes[key] = self.response.container.get(key + "Code", "-1").asInt() + self.decisionsTexts[key] = self.response.container.get(key + "Text") + + util.DEBUG_LOG("Decision codes: {0}".format(self.decisionsCodes)) + + def __str__(self): + if self.isSupported: + obj = [] + for v in self.decisionsTexts.values(): + if v: + obj.append(v) + return ' '.join(obj) + else: + return "Server version does not support decisions." + + def __repr__(self): + return self.__str__() + + def getDecision(self, requireDecision=True): + if not self.item: + # Return no decision. The player will either continue with the original + # or terminate if a valid decision was required. + + if requireDecision: + # Terminate the player by default if there was no decision returned. + code = self.decisionsCodes["generalDecision"] + reason = ' '.join([self.decisionsTexts["transcodeDecision"], self.decisionsTexts["generalDecision"]]) + raise DecisionFailure(code, reason) + + return None + + # Rebuild the original item with the new item. + util.WARN_LOG("Server requested new playback decision: {0}".format(self)) + self.original.rebuild(self.item, self) + return self.original + + def isSuccess(self): + code = self.decisionsCodes["mdeDecision"] + return not self.isSupported or 1000 <= code < 2000 + + def isDecision(self, requireItem=False): + # Server has provided a valid decision if there was a valid decision code + # or if the response returned zero items (could not play). + return self.isSupported and (self.decisionsCodes["mdeDecision"] > -1 or requireItem and not self.item) + + def isTimelineDecision(self): + return self.isSupported and self.item + + def isTermination(self): + return self.isSupported and self.decisionsCodes["termination"] > -1 + + def directPlayOK(self): + return self.decisionsCodes["mdeDecision"] == 1000 + + def getTermination(self): + return { + 'code': str(self.decisionsCodes["termination"]), + 'text': self.decisionsTexts["termination"] or "Unknown" # TODO: Translate Unknown + } + + def getDecisionText(self): + for key in ["mdeDecision", "directPlayDecision", "generalDecision", "transcodeDecision"]: + if self.decisionsTexts.get(key): + return self.decisionsTexts[key] + return None diff --git a/script.plexmod/lib/_included_packages/plexnet/signalslot/__init__.py b/script.plexmod/lib/_included_packages/plexnet/signalslot/__init__.py new file mode 100644 index 000000000..b9c234ff3 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/signalslot/__init__.py @@ -0,0 +1,11 @@ +from __future__ import absolute_import +try: + from .signal import Signal + from .slot import Slot + from .exceptions import * +except ImportError: # pragma: no cover + # Possible we are running from setup.py, in which case we're after + # the __version__ string only. + pass + +__version__ = '0.1.1' diff --git a/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/__init__.py b/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/task/__init__.py b/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/task/__init__.py new file mode 100644 index 000000000..280e6a2ad --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/task/__init__.py @@ -0,0 +1 @@ +from .task import Task diff --git a/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/task/task.py b/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/task/task.py new file mode 100644 index 000000000..b6c81c1d2 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/task/task.py @@ -0,0 +1,76 @@ +from __future__ import absolute_import +import sys +import eventlet +import contexter +import six + + +class Task(object): + @classmethod + def get_or_create(cls, signal, kwargs=None, logger=None): + if not hasattr(cls, '_registry'): + cls._registry = [] + + task = cls(signal, kwargs, logger=logger) + + if task not in cls._registry: + cls._registry.append(task) + + return cls._registry[cls._registry.index(task)] + + def __init__(self, signal, kwargs=None, logger=None): + self.signal = signal + self.kwargs = kwargs or {} + self.logger = logger + self.failures = 0 + self.task_semaphore = eventlet.semaphore.BoundedSemaphore(1) + + def __call__(self, semaphores=None): + semaphores = semaphores or [] + + with contexter.Contexter(self.task_semaphore, *semaphores): + result = self._do() + + if result: + self.failures = 0 + else: + self.failures += 1 + + return result + + def _do(self): + try: + self._emit() + except Exception: + self._exception(*sys.exc_info()) + return False + else: + self._completed() + return True + finally: + self._clean() + + def _clean(self): + pass + + def _completed(self): + if self.logger: + self.logger.info('[%s] Completed' % self) + + def _exception(self, e_type, e_value, e_traceback): + if self.logger: + self.logger.exception('[%s] Raised exception: %s' % ( + self, e_value)) + else: + six.reraise(e_type, e_value, e_traceback) + + def _emit(self): + if self.logger: + self.logger.info('[%s] Running' % self) + self.signal.emit(**self.kwargs) + + def __eq__(self, other): + return (self.signal == other.signal and self.kwargs == other.kwargs) + + def __str__(self): + return '%s: %s' % (self.signal.__class__.__name__, self.kwargs) diff --git a/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/task/test.py b/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/task/test.py new file mode 100644 index 000000000..c35640872 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/signalslot/contrib/task/test.py @@ -0,0 +1,185 @@ +from __future__ import absolute_import +import pytest +import mock +import logging +import eventlet +import time +from signalslot import Signal +from signalslot.contrib.task import Task + +eventlet.monkey_patch(time=True) + + +class TestTask(object): + def setup_method(self, method): + self.signal = mock.Mock() + + def get_task_mock(self, *methods, **kwargs): + if kwargs.get('logger'): + log = logging.getLogger('TestTask') + else: + log = None + task_mock = Task(self.signal, logger=log) + + for method in methods: + setattr(task_mock, method, mock.Mock()) + + return task_mock + + def test_eq(self): + x = Task(self.signal, dict(some_kwarg='foo'), + logger=logging.getLogger('TaskX')) + y = Task(self.signal, dict(some_kwarg='foo'), + logger=logging.getLogger('TaskY')) + + assert x == y + + def test_not_eq(self): + x = Task(self.signal, dict(some_kwarg='foo', + logger=logging.getLogger('TaskX'))) + y = Task(self.signal, dict(some_kwarg='bar', + logger=logging.getLogger('TaskY'))) + + assert x != y + + def test_unicode(self): + t = Task(self.signal, dict(some_kwarg='foo'), + logger=logging.getLogger('TaskT')) + + assert str(t) == "Mock: {'some_kwarg': 'foo'}" + + def test_get_or_create_gets(self): + x = Task.get_or_create(self.signal, dict(some_kwarg='foo'), + logger=logging.getLogger('TaskX')) + y = Task.get_or_create(self.signal, dict(some_kwarg='foo'), + logger=logging.getLogger('TaskY')) + + assert x is y + + def test_get_or_create_creates(self): + x = Task.get_or_create(self.signal, dict(some_kwarg='foo'), + logger=logging.getLogger('TaskX')) + y = Task.get_or_create(self.signal, dict(some_kwarg='bar'), + logger=logging.getLogger('TaskY')) + + assert x is not y + + def test_get_or_create_without_kwargs(self): + t = Task.get_or_create(self.signal) + + assert t.kwargs == {} + + def test_get_or_create_uses_cls(self): + class Foo(Task): + pass + + assert isinstance(Foo.get_or_create(self.signal), Foo) + + def test_do_emit(self): + task_mock = self.get_task_mock('_clean', '_exception', '_completed') + + task_mock._do() + + self.signal.emit.assert_called_once_with() + + def test_do_emit_nolog(self): + task_mock = self.get_task_mock( + '_clean', '_exception', '_completed', logging=True) + + task_mock._do() + + self.signal.emit.assert_called_once_with() + + def test_do_emit_no_log(self): + task_mock = self.get_task_mock('_clean', '_exception', '_completed') + + task_mock._do() + + self.signal.emit.assert_called_once_with() + + def test_do_complete(self): + task_mock = self.get_task_mock('_clean', '_exception', '_completed') + + task_mock._do() + + task_mock._exception.assert_not_called() + task_mock._completed.assert_called_once_with() + task_mock._clean.assert_called_once_with() + + def test_do_success(self): + task_mock = self.get_task_mock() + assert task_mock._do() is True + + def test_do_failure_nolog(self): + # Our dummy exception + class DummyError(Exception): + pass + + task_mock = self.get_task_mock('_emit') + task_mock._emit.side_effect = DummyError() + + # This will throw an exception at us, be ready to catch it. + try: + task_mock._do() + assert False + except DummyError: + pass + + def test_do_failure_withlog(self): + task_mock = self.get_task_mock('_emit', logger=True) + task_mock._emit.side_effect = Exception() + assert task_mock._do() is False + + def test_do_exception(self): + task_mock = self.get_task_mock( + '_clean', '_exception', '_completed', '_emit') + + task_mock._emit.side_effect = Exception() + + task_mock._do() + + task_mock._exception.assert_called_once_with( + Exception, task_mock._emit.side_effect, mock.ANY) + + task_mock._completed.assert_not_called() + task_mock._clean.assert_called_once_with() + + @mock.patch('signalslot.signal.inspect') + def test_semaphore(self, inspect): + slot = mock.Mock() + slot.side_effect = lambda **k: time.sleep(.3) + + signal = Signal('tost') + signal.connect(slot) + + x = Task.get_or_create(signal, dict(some_kwarg='foo'), + logger=logging.getLogger('TaskX')) + y = Task.get_or_create(signal, dict(some_kwarg='foo'), + logger=logging.getLogger('TaskY')) + + eventlet.spawn(x) + time.sleep(.1) + eventlet.spawn(y) + time.sleep(.1) + + assert slot.call_count == 1 + time.sleep(.4) + assert slot.call_count == 2 + + def test_call_context(self): + task_mock = self.get_task_mock('_clean', '_exception', '_completed', + '_emit') + + task_mock._emit.side_effect = Exception() + + assert task_mock.failures == 0 + task_mock() + assert task_mock.failures == 1 + + def test_call_success(self): + task_mock = self.get_task_mock('_clean', '_exception', '_completed', + '_emit') + + assert task_mock.failures == 0 + task_mock() + assert task_mock.failures == 0 diff --git a/script.plexmod/lib/_included_packages/plexnet/signalslot/exceptions.py b/script.plexmod/lib/_included_packages/plexnet/signalslot/exceptions.py new file mode 100644 index 000000000..272b1b347 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/signalslot/exceptions.py @@ -0,0 +1,28 @@ +class SignalSlotException(Exception): + """Base class for all exceptions of this module.""" + pass + + +class SlotMustAcceptKeywords(SignalSlotException): + """ + Raised when connecting a slot that does not accept ``**kwargs`` in its + signature. + """ + def __init__(self, signal, slot): + m = 'Cannot connect %s to %s because it does not accept **kwargs' % ( + slot, signal) + + super(SlotMustAcceptKeywords, self).__init__(m) + + +# Not yet being used. +class QueueCantQueueNonSignalInstance(SignalSlotException): # pragma: no cover + """ + Raised when trying to queue something else than a + :py:class:`~signalslot.signal.Signal` instance. + """ + def __init__(self, queue, arg): + m = 'Cannot queue %s to %s because it is not a Signal instance' % ( + arg, queue) + + super(QueueCantQueueNonSignalInstance, self).__init__(m) diff --git a/script.plexmod/lib/_included_packages/plexnet/signalslot/signal.py b/script.plexmod/lib/_included_packages/plexnet/signalslot/signal.py new file mode 100644 index 000000000..435461ee0 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/signalslot/signal.py @@ -0,0 +1,176 @@ +""" +Module defining the Signal class. +""" + +from __future__ import absolute_import +import inspect +import threading + +from . import exceptions + + +class DummyLock(object): + """ + Class that implements a no-op instead of a re-entrant lock. + """ + + def __enter__(self): + pass + + def __exit__(self, exc_type=None, exc_value=None, traceback=None): + pass + + +class BaseSlot(object): + """ + Slot abstract class for type resolution purposes. + """ + pass + + +class Signal(object): + """ + Define a signal by instanciating a :py:class:`Signal` object, ie.: + + >>> conf_pre_load = Signal() + + Optionaly, you can declare a list of argument names for this signal, ie.: + + >>> conf_pre_load = Signal(args=['conf']) + + Any callable can be connected to a Signal, it **must** accept keywords + (``**kwargs``), ie.: + + >>> def yourmodule_conf(conf, **kwargs): + ... conf['yourmodule_option'] = 'foo' + ... + + Connect your function to the signal using :py:meth:`connect`: + + >>> conf_pre_load.connect(yourmodule_conf) + + Emit the signal to call all connected callbacks using + :py:meth:`emit`: + + >>> conf = {} + >>> conf_pre_load.emit(conf=conf) + >>> conf + {'yourmodule_option': 'foo'} + + Note that you may disconnect a callback from a signal if it is already + connected: + + >>> conf_pre_load.is_connected(yourmodule_conf) + True + >>> conf_pre_load.disconnect(yourmodule_conf) + >>> conf_pre_load.is_connected(yourmodule_conf) + False + """ + def __init__(self, args=None, name=None, threadsafe=False): + self._slots = [] + self._slots_lk = threading.RLock() if threadsafe else DummyLock() + self.args = args or [] + self.name = name + + @property + def slots(self): + """ + Return a list of slots for this signal. + """ + with self._slots_lk: + # Do a slot clean-up + slots = [] + for s in self._slots: + if isinstance(s, BaseSlot) and (not s.is_alive): + continue + slots.append(s) + self._slots = slots + return list(slots) + + def connect(self, slot): + """ + Connect a callback ``slot`` to this signal. + """ + if not isinstance(slot, BaseSlot): + if getattr(inspect, "getfullargspec", False): + try: + if inspect.getfullargspec(slot).varkw is None: + raise exceptions.SlotMustAcceptKeywords(self, slot) + except TypeError: + if inspect.getfullargspec(slot.__call__).varkw is None: + raise exceptions.SlotMustAcceptKeywords(self, slot) + else: + try: + if inspect.getargspec(slot).keywords is None: + raise exceptions.SlotMustAcceptKeywords(self, slot) + except TypeError: + if inspect.getargspec(slot.__call__).keywords is None: + raise exceptions.SlotMustAcceptKeywords(self, slot) + + with self._slots_lk: + if not self.is_connected(slot): + self._slots.append(slot) + + def is_connected(self, slot): + """ + Check if a callback ``slot`` is connected to this signal. + """ + with self._slots_lk: + return slot in self._slots + + def disconnect(self, slot): + """ + Disconnect a slot from a signal if it is connected else do nothing. + """ + with self._slots_lk: + if self.is_connected(slot): + self._slots.pop(self._slots.index(slot)) + + def emit(self, **kwargs): + """ + Emit this signal which will execute every connected callback ``slot``, + passing keyword arguments. + + If a slot returns anything other than None, then :py:meth:`emit` will + return that value preventing any other slot from being called. + + >>> need_something = Signal() + >>> def get_something(**kwargs): + ... return 'got something' + ... + >>> def make_something(**kwargs): + ... print('I will not be called') + ... + >>> need_something.connect(get_something) + >>> need_something.connect(make_something) + >>> need_something.emit() + 'got something' + """ + for slot in reversed(self.slots): + result = slot(**kwargs) + + if result is not None: + return result + + def __eq__(self, other): + """ + Return True if other has the same slots connected. + + >>> a = Signal() + >>> b = Signal() + >>> a == b + True + >>> def slot(**kwargs): + ... pass + ... + >>> a.connect(slot) + >>> a == b + False + >>> b.connect(slot) + >>> a == b + True + """ + return self.slots == other.slots + + def __repr__(self): + return '' % (self.name or 'NO_NAME') diff --git a/script.plexmod/lib/_included_packages/plexnet/signalslot/slot.py b/script.plexmod/lib/_included_packages/plexnet/signalslot/slot.py new file mode 100644 index 000000000..18363e796 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/signalslot/slot.py @@ -0,0 +1,74 @@ +""" +Module defining the Slot class. +""" + +from __future__ import absolute_import +import types +import weakref +import sys + +from .signal import BaseSlot + +# We cannot test a branch for Python >= 3.4 in Python < 3.4. +if sys.version_info < (3, 4): # pragma: no cover + from weakrefmethod import WeakMethod +else: # pragma: no cover + from weakref import WeakMethod + + +class Slot(BaseSlot): + """ + A slot is a callable object that manages a connection to a signal. + If weak is true or the slot is a subclass of weakref.ref, the slot + is automatically de-referenced to the called function. + """ + def __init__(self, slot, weak=False): + self._weak = weak or isinstance(slot, weakref.ref) + if weak and not isinstance(slot, weakref.ref): + if isinstance(slot, types.MethodType): + slot = WeakMethod(slot) + else: + slot = weakref.ref(slot) + self._slot = slot + + @property + def is_alive(self): + """ + Return True if this slot is "alive". + """ + return (not self._weak) or (self._slot() is not None) + + @property + def func(self): + """ + Return the function that is called by this slot. + """ + if self._weak: + return self._slot() + else: + return self._slot + + def __call__(self, **kwargs): + """ + Execute this slot. + """ + func = self.func + if func is not None: + return func(**kwargs) + + def __eq__(self, other): + """ + Compare this slot to another. + """ + if isinstance(other, BaseSlot): + return self.func == other.func + else: + return self.func == other + + def __repr__(self): + fn = self.func + if fn is None: + fn = 'dead' + else: + fn = repr(fn) + return '' % fn diff --git a/script.plexmod/lib/_included_packages/plexnet/signalslot/tests.py b/script.plexmod/lib/_included_packages/plexnet/signalslot/tests.py new file mode 100644 index 000000000..87ff3dce8 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/signalslot/tests.py @@ -0,0 +1,206 @@ +from __future__ import absolute_import +import pytest +import mock + +from signalslot import Signal, SlotMustAcceptKeywords, Slot + + +@mock.patch('signalslot.signal.inspect') +class TestSignal(object): + def setup_method(self, method): + self.signal_a = Signal(threadsafe=True) + self.signal_b = Signal(args=['foo']) + + self.slot_a = mock.Mock(spec=lambda **kwargs: None) + self.slot_a.return_value = None + self.slot_b = mock.Mock(spec=lambda **kwargs: None) + self.slot_b.return_value = None + + def test_is_connected(self, inspect): + self.signal_a.connect(self.slot_a) + + assert self.signal_a.is_connected(self.slot_a) + assert not self.signal_a.is_connected(self.slot_b) + assert not self.signal_b.is_connected(self.slot_a) + assert not self.signal_b.is_connected(self.slot_b) + + def test_emit_one_slot(self, inspect): + self.signal_a.connect(self.slot_a) + + self.signal_a.emit() + + self.slot_a.assert_called_once_with() + assert self.slot_b.call_count == 0 + + def test_emit_two_slots(self, inspect): + self.signal_a.connect(self.slot_a) + self.signal_a.connect(self.slot_b) + + self.signal_a.emit() + + self.slot_a.assert_called_once_with() + self.slot_b.assert_called_once_with() + + def test_emit_one_slot_with_arguments(self, inspect): + self.signal_b.connect(self.slot_a) + + self.signal_b.emit(foo='bar') + + self.slot_a.assert_called_once_with(foo='bar') + assert self.slot_b.call_count == 0 + + def test_emit_two_slots_with_arguments(self, inspect): + self.signal_b.connect(self.slot_a) + self.signal_b.connect(self.slot_b) + + self.signal_b.emit(foo='bar') + + self.slot_a.assert_called_once_with(foo='bar') + self.slot_b.assert_called_once_with(foo='bar') + + def test_reconnect_does_not_duplicate(self, inspect): + self.signal_a.connect(self.slot_a) + self.signal_a.connect(self.slot_a) + self.signal_a.emit() + + self.slot_a.assert_called_once_with() + + def test_disconnect_does_not_fail_on_not_connected_slot(self, inspect): + self.signal_a.disconnect(self.slot_b) + + +def test_anonymous_signal_has_nice_repr(): + signal = Signal() + assert repr(signal) == '' + + +def test_named_signal_has_a_nice_repr(): + signal = Signal(name='update_stuff') + assert repr(signal) == '' + + +class TestSignalConnect(object): + def setup_method(self, method): + self.signal = Signal() + + def test_connect_with_kwargs(self): + def cb(**kwargs): + pass + + self.signal.connect(cb) + + def test_connect_without_kwargs(self): + def cb(): + pass + + with pytest.raises(SlotMustAcceptKeywords): + self.signal.connect(cb) + + +class MyTestError(Exception): + pass + + +class TestException(object): + def setup_method(self, method): + self.signal = Signal(threadsafe=False) + self.seen_exception = False + + def failing_slot(**args): + raise MyTestError('die!') + + self.signal.connect(failing_slot) + + def test_emit_exception(self): + try: + self.signal.emit() + except MyTestError: + self.seen_exception = True + + assert self.seen_exception + + +class TestStrongSlot(object): + def setup_method(self, method): + self.called = False + + def slot(**kwargs): + self.called = True + + self.slot = Slot(slot) + + def test_alive(self): + assert self.slot.is_alive + + def test_call(self): + self.slot(testing=1234) + assert self.called + + +class TestWeakFuncSlot(object): + def setup_method(self, method): + self.called = False + + def slot(**kwargs): + self.called = True + + self.slot = Slot(slot, weak=True) + self.slot_ref = slot + + def test_alive(self): + assert self.slot.is_alive + assert repr(self.slot) == '' % repr(self.slot_ref) + + def test_call(self): + self.slot(testing=1234) + assert self.called + + def test_gc(self): + self.slot_ref = None + assert not self.slot.is_alive + assert repr(self.slot) == '' + self.slot(testing=1234) + + +class TestWeakMethodSlot(object): + def setup_method(self, method): + + class MyObject(object): + + def __init__(self): + self.called = False + + def slot(self, **kwargs): + self.called = True + + self.obj_ref = MyObject() + self.slot = Slot(self.obj_ref.slot, weak=True) + self.signal = Signal() + self.signal.connect(self.slot) + + def test_alive(self): + assert self.slot.is_alive + + def test_call(self): + self.signal.emit(testing=1234) + assert self.obj_ref.called + + def test_gc(self): + self.obj_ref = None + assert not self.slot.is_alive + self.signal.emit(testing=1234) + + +class TestSlotEq(object): + def setup_method(self, method): + self.slot_a = Slot(self.slot, weak=False) + self.slot_b = Slot(self.slot, weak=True) + + def slot(self, **kwargs): + pass + + def test_eq_other(self): + assert self.slot_a == self.slot_b + + def test_eq_func(self): + assert self.slot_a == self.slot diff --git a/script.plexmod/lib/_included_packages/plexnet/signalsmixin.py b/script.plexmod/lib/_included_packages/plexnet/signalsmixin.py new file mode 100644 index 000000000..2efe9d6e6 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/signalsmixin.py @@ -0,0 +1,41 @@ +from __future__ import absolute_import +from . import signalslot + + +class SignalsMixin(object): + def __init__(self): + self._signals = {} + + def on(self, signalName, callback): + if signalName not in self._signals: + self._signals[signalName] = signalslot.Signal(threadsafe=True) + + signal = self._signals[signalName] + + signal.connect(callback) + + def off(self, signalName, callback): + if not self._signals: + return + + if not signalName: + if not callback: + self._signals = {} + else: + for name in self._signals: + self.off(name, callback) + else: + if not callback: + if signalName in self._signals: + del self._signals[signalName] + else: + self._signals[signalName].disconnect(callback) + + def trigger(self, signalName, **kwargs): + if not self._signals: + return + + if signalName not in self._signals: + return + + self._signals[signalName].emit(**kwargs) diff --git a/script.plexmod/lib/_included_packages/plexnet/simpleobjects.py b/script.plexmod/lib/_included_packages/plexnet/simpleobjects.py new file mode 100644 index 000000000..16b7b8a78 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/simpleobjects.py @@ -0,0 +1,21 @@ +class Res(tuple): + def __str__(self): + return '{0}x{1}'.format(*self[:2]) + + @classmethod + def fromString(cls, res_string): + try: + return cls([int(n) for n in res_string.split('x')]) + except: + return None + + +class AttributeDict(dict): + def __getattr__(self, attr): + return self.get(attr) + + def __setattr__(self, attr, value): + self[attr] = value + + def __repr__(self): + return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.id, self.get('title', 'None').encode('utf8')) diff --git a/script.plexmod/lib/_included_packages/plexnet/threadutils.py b/script.plexmod/lib/_included_packages/plexnet/threadutils.py new file mode 100644 index 000000000..fda46b4de --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/threadutils.py @@ -0,0 +1,94 @@ +# import inspect +# import ctypes +from __future__ import absolute_import +import threading +# import time + + +# def _async_raise(tid, exctype): +# '''Raises an exception in the threads with id tid''' +# if not inspect.isclass(exctype): +# raise TypeError("Only types can be raised (not instances)") + +# try: +# res = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), ctypes.py_object(exctype)) +# except AttributeError: +# # To catch: undefined symbol: PyThreadState_SetAsyncExc +# return + +# if res == 0: +# raise ValueError("invalid thread id") +# elif res != 1: +# # "if it returns a number greater than one, you're in trouble, +# # and you should call it again with exc=NULL to revert the effect" +# ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0) +# raise SystemError("PyThreadState_SetAsyncExc failed") + + +# class KillThreadException(Exception): +# pass + + +class KillableThread(threading.Thread): + pass + '''A thread class that supports raising exception in the thread from + another thread. + ''' + # def _get_my_tid(self): + # """determines this (self's) thread id + + # CAREFUL : this function is executed in the context of the caller + # thread, to get the identity of the thread represented by this + # instance. + # """ + # if not self.is_alive(): + # raise threading.ThreadError("the thread is not active") + + # return self.ident + + # def _raiseExc(self, exctype): + # """Raises the given exception type in the context of this thread. + + # If the thread is busy in a system call (time.sleep(), + # socket.accept(), ...), the exception is simply ignored. + + # If you are sure that your exception should terminate the thread, + # one way to ensure that it works is: + + # t = ThreadWithExc( ... ) + # ... + # t.raiseExc( SomeException ) + # while t.is_alive(): + # time.sleep( 0.1 ) + # t.raiseExc( SomeException ) + + # If the exception is to be caught by the thread, you need a way to + # check that your thread has caught it. + + # CAREFUL : this function is executed in the context of the + # caller thread, to raise an excpetion in the context of the + # thread represented by this instance. + # """ + # _async_raise(self._get_my_tid(), exctype) + + def kill(self, force_and_wait=False): + pass + # try: + # self._raiseExc(KillThreadException) + + # if force_and_wait: + # time.sleep(0.1) + # while self.is_alive(): + # self._raiseExc(KillThreadException) + # time.sleep(0.1) + # except threading.ThreadError: + # pass + + # def onKilled(self): + # pass + + # def run(self): + # try: + # self._Thread__target(*self._Thread__args, **self._Thread__kwargs) + # except KillThreadException: + # self.onKilled() diff --git a/script.plexmod/lib/_included_packages/plexnet/util.py b/script.plexmod/lib/_included_packages/plexnet/util.py new file mode 100644 index 000000000..031b36d49 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/util.py @@ -0,0 +1,311 @@ +from __future__ import absolute_import +from . import simpleobjects +import re +import sys +import time +import platform +import uuid +import threading +import six +from kodi_six import xbmcaddon + +from . import verlib +from . import compat + +if six.PY2: + Event = threading._Event +else: + Event = threading.Event + +BASE_HEADERS = '' + +# to maintain py2 compatibility, duplicate ADDON from lib.util to avoid circular import +ADDON = xbmcaddon.Addon() + + +def resetBaseHeaders(): + return { + 'X-Plex-Platform': X_PLEX_PLATFORM, + 'X-Plex-Platform-Version': X_PLEX_PLATFORM_VERSION, + 'X-Plex-Provides': X_PLEX_PROVIDES, + 'X-Plex-Product': "PM4K", + 'X-Plex-Version': ADDON.getAddonInfo('version'), + 'X-Plex-Device': X_PLEX_DEVICE, + 'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER, + 'Accept-Encoding': 'gzip,deflate', + 'Accept-Language': ACCEPT_LANGUAGE, + 'User-Agent': '{0}/{1}'.format("PM4K", ADDON.getAddonInfo('version')) + } + + +# Core Settings +PROJECT = 'PlexNet' # name provided to plex server +VERSION = '0.0.0a1' # version of this api +TIMEOUT = 10 # request timeout +LONG_TIMEOUT = 20 # s +CONN_CHECK_TIMEOUT = 2.5 # s +LAN_REACHABILITY_TIMEOUT = 10 # ms +CHECK_LOCAL = False +LOCAL_OVER_SECURE = False +X_PLEX_CONTAINER_SIZE = 50 # max results to return in a single search page + +ACCEPT_LANGUAGE = 'en-US,en' + +# Plex Header Configuation +X_PLEX_PROVIDES = 'player,controller' # one or more of [player, controller, server] +X_PLEX_PLATFORM = platform.uname()[0] # Platform name, eg iOS, MacOSX, Android, LG, etc +X_PLEX_PLATFORM_VERSION = platform.uname()[2] # Operating system version, eg 4.3.1, 10.6.7, 3.2 +X_PLEX_PRODUCT = PROJECT # Plex application name, eg Laika, Plex Media Server, Media Link +X_PLEX_VERSION = VERSION # Plex application version number +USER_AGENT = '{0}/{1}'.format(PROJECT, VERSION) + +INTERFACE = None +TIMER = None +APP = None +MANAGER = None + +try: + _platform = platform.system() +except: + try: + _platform = platform.platform(terse=True) + except: + _platform = sys.platform + +X_PLEX_DEVICE = _platform # Device name and model number, eg iPhone3,2, Motorola XOOM, LG5200TV +X_PLEX_IDENTIFIER = str(hex(uuid.getnode())) # UUID, serial number, or other number unique per device + +BASE_HEADERS = resetBaseHeaders() + +QUALITY_LOCAL = 0 +QUALITY_REMOTE = 1 +QUALITY_ONLINE = 2 + +Res = simpleobjects.Res +AttributeDict = simpleobjects.AttributeDict + + +def setInterface(interface): + global INTERFACE + INTERFACE = interface + + +def setTimer(timer): + global TIMER + TIMER = timer + + +def setApp(app): + global APP + APP = app + + +def LOG(msg): + INTERFACE.LOG(msg) + + +def DEBUG_LOG(msg): + INTERFACE.DEBUG_LOG(msg) + + +def ERROR_LOG(msg): + INTERFACE.ERROR_LOG(msg) + + +def WARN_LOG(msg): + INTERFACE.WARN_LOG(msg) + + +def ERROR(msg=None, err=None): + INTERFACE.ERROR(msg, err) + + +def FATAL(msg=None): + INTERFACE.FATAL(msg) + + +def TEST(msg): + INTERFACE.LOG(' ---TEST: {0}'.format(msg)) + + +def userAgent(): + return INTERFACE.getGlobal("userAgent") + + +def dummyTranslate(string): + return string + + +def hideToken(token): + # return 'X' * len(token) + if not token: + return token + return '****' + token[-4:] + + +def cleanToken(url): + return re.sub('X-Plex-Token=[^&]+', 'X-Plex-Token=****', url) + + +def now(local=False): + if local: + return time.time() + else: + return time.mktime(time.gmtime()) + + +def joinArgs(args, includeQuestion=True): + if not args: + return '' + + arglist = [] + for key in sorted(args, key=lambda x: x.lower()): + value = str(args[key]) + arglist.append('{0}={1}'.format(key, compat.quote(value))) + + return '{0}{1}'.format(includeQuestion and '?' or '&', '&'.join(arglist)) + + +def addPlexHeaders(transferObj, token=None): + headers = {"X-Plex-Platform": INTERFACE.getGlobal("platform"), + "X-Plex-Version": INTERFACE.getGlobal("appVersionStr"), + "X-Plex-Client-Identifier": INTERFACE.getGlobal("clientIdentifier"), + "X-Plex-Platform-Version": INTERFACE.getGlobal("platformVersion", "unknown"), + "X-Plex-Product": INTERFACE.getGlobal("product"), + "X-Plex-Provides": not INTERFACE.getPreference("remotecontrol", False) and 'player' or '', + "X-Plex-Device": INTERFACE.getGlobal("device"), + "X-Plex-Model": INTERFACE.getGlobal("model"), + "X-Plex-Device-Name": INTERFACE.getGlobal("friendlyName"), + } + + transferObj.session.headers.update(headers) + + # Adding the X-Plex-Client-Capabilities header causes node.plexapp.com to 500 + if not type(transferObj) == "roUrlTransfer" or 'node.plexapp.com' not in transferObj.getUrl(): + transferObj.addHeader("X-Plex-Client-Capabilities", INTERFACE.getCapabilities()) + + addAccountHeaders(transferObj, token) + + +def addAccountHeaders(transferObj, token=None): + if token: + transferObj.addHeader("X-Plex-Token", token) + + # TODO(schuyler): Add username? + + +def validInt(int_str): + try: + return int(int_str) + except: + return 0 + + +def bitrateToString(bits): + if not bits: + return '' + + speed = bits / 1000000.0 + if speed < 1: + speed = int(round(bits / 1000.0)) + return '{0} Kbps'.format(speed) + else: + return '{0:.1f} Mbps'.format(speed) + + +def normalizedVersion(ver): + try: + modv = '.'.join(ver.split('.')[:4]).split('-', 1)[0] # Clean the version i.e. Turn 1.2.3.4-asdf8-ads7f into 1.2.3.4 + return verlib.NormalizedVersion(verlib.suggest_normalized_version(modv)) + except: + if ver: + ERROR() + return verlib.NormalizedVersion(verlib.suggest_normalized_version('0.0.0')) + + +class CompatEvent(Event): + def wait(self, timeout): + Event.wait(self, timeout) + return self.isSet() + + +class Timer(object): + def __init__(self, timeout, function, repeat=False, name=None, fname=None, *args, **kwargs): + self.function = function + self.timeout = timeout + self.repeat = repeat + self.args = args + self.kwargs = kwargs + self._reset = False + self.name = name or 'TIMER:{0}'.format(self.function) + self.fname = fname or repr(self.function) + self.event = CompatEvent() + self.start() + + def start(self): + self.event.clear() + self.thread = threading.Thread(target=self.run, name=self.name, *self.args, **self.kwargs) + self.thread.start() + + def run(self): + DEBUG_LOG('Timer {0}: {1}'.format(self.fname, self._reset and 'RESET'or 'STARTED')) + try: + while not self.event.isSet() and not self.shouldAbort(): + while not self.event.wait(self.timeout) and not self.shouldAbort(): + if self._reset: + return + + self.function(*self.args, **self.kwargs) + if not self.repeat: + return + finally: + if not self._reset: + if self in APP.timers: + APP.timers.remove(self) + + DEBUG_LOG('Timer {0}: FINISHED'.format(self.fname)) + + self._reset = False + + def cancel(self): + self.event.set() + + def reset(self): + self._reset = True + self.cancel() + if self.thread and self.thread.is_alive(): + self.thread.join() + self.start() + + def is_alive(self): + return self.thread and self.thread.is_alive() + + def shouldAbort(self): + return False + + def join(self): + if self.thread.is_alive(): + self.thread.join() + + def isExpired(self): + return self.event.isSet() + + +class RepeatingCounterTimer(Timer): + def __init__(self, timeout, function, repeat=True, *args, **kwargs): + self.ticks = 0 + self._function = function + super(RepeatingCounterTimer, self).__init__(timeout, self.count, repeat=repeat, + name='TIMER:{0}'.format(function), + fname=repr(function), *args, **kwargs) + + def count(self): + self.ticks += 1 + self._function(*self.args, **self.kwargs) + + def reset(self): + super(RepeatingCounterTimer, self).reset() + self.ticks = 0 + + +TIMER = Timer diff --git a/script.plexmod/lib/_included_packages/plexnet/verlib.py b/script.plexmod/lib/_included_packages/plexnet/verlib.py new file mode 100644 index 000000000..a06889031 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/verlib.py @@ -0,0 +1,329 @@ +""" +"Rational" version definition and parsing for DistutilsVersionFight +discussion at PyCon 2009. +""" +from __future__ import absolute_import +import re + + +class IrrationalVersionError(Exception): + """This is an irrational version.""" + pass + + +class HugeMajorVersionNumError(IrrationalVersionError): + """An irrational version because the major version number is huge + (often because a year or date was used). + + See `error_on_huge_major_num` option in `NormalizedVersion` for details. + This guard can be disabled by setting that option False. + """ + pass + +# A marker used in the second and third parts of the `parts` tuple, for +# versions that don't have those segments, to sort properly. An example +# of versions in sort order ('highest' last): +# 1.0b1 ((1,0), ('b',1), ('f',)) +# 1.0.dev345 ((1,0), ('f',), ('dev', 345)) +# 1.0 ((1,0), ('f',), ('f',)) +# 1.0.post256.dev345 ((1,0), ('f',), ('f', 'post', 256, 'dev', 345)) +# 1.0.post345 ((1,0), ('f',), ('f', 'post', 345, 'f')) +# ^ ^ ^ +# 'b' < 'f' ---------------------/ | | +# | | +# 'dev' < 'f' < 'post' -------------------/ | +# | +# 'dev' < 'f' ----------------------------------------------/ +# Other letters would do, but 'f' for 'final' is kind of nice. +FINAL_MARKER = ('f',) + +VERSION_RE = re.compile(r''' + ^ + (?P\d+\.\d+) # minimum 'N.N' + (?P(?:\.\d+)*) # any number of extra '.N' segments + (?: + (?P[abc]|rc) # 'a'=alpha, 'b'=beta, 'c'=release candidate + # 'rc'= alias for release candidate + (?P\d+(?:\.\d+)*) + )? + (?P(\.post(?P\d+))?(\.dev(?P\d+))?)? + $''', re.VERBOSE) + + +class NormalizedVersion(object): + """A rational version. + + Good: + 1.2 # equivalent to "1.2.0" + 1.2.0 + 1.2a1 + 1.2.3a2 + 1.2.3b1 + 1.2.3c1 + 1.2.3.4 + TODO: fill this out + + Bad: + 1 # mininum two numbers + 1.2a # release level must have a release serial + 1.2.3b + """ + + def __init__(self, s, error_on_huge_major_num=True): + """Create a NormalizedVersion instance from a version string. + + @param s {str} The version string. + @param error_on_huge_major_num {bool} Whether to consider an + apparent use of a year or full date as the major version number + an error. Default True. One of the observed patterns on PyPI before + the introduction of `NormalizedVersion` was version numbers like this: + 2009.01.03 + 20040603 + 2005.01 + This guard is here to strongly encourage the package author to + use an alternate version, because a release deployed into PyPI + and, e.g. downstream Linux package managers, will forever remove + the possibility of using a version number like "1.0" (i.e. + where the major number is less than that huge major number). + """ + self._parse(s, error_on_huge_major_num) + + @classmethod + def from_parts(cls, version, prerelease=FINAL_MARKER, + devpost=FINAL_MARKER): + return cls(cls.parts_to_str((version, prerelease, devpost))) + + def _parse(self, s, error_on_huge_major_num=True): + """Parses a string version into parts.""" + match = VERSION_RE.search(s) + if not match: + raise IrrationalVersionError(s) + + groups = match.groupdict() + parts = [] + + # main version + block = self._parse_numdots(groups['version'], s, False, 2) + extraversion = groups.get('extraversion') + if extraversion not in ('', None): + block += self._parse_numdots(extraversion[1:], s) + parts.append(tuple(block)) + + # prerelease + prerel = groups.get('prerel') + if prerel is not None: + block = [prerel] + block += self._parse_numdots(groups.get('prerelversion'), s, + pad_zeros_length=1) + parts.append(tuple(block)) + else: + parts.append(FINAL_MARKER) + + # postdev + if groups.get('postdev'): + post = groups.get('post') + dev = groups.get('dev') + postdev = [] + if post is not None: + postdev.extend([FINAL_MARKER[0], 'post', int(post)]) + if dev is None: + postdev.append(FINAL_MARKER[0]) + if dev is not None: + postdev.extend(['dev', int(dev)]) + parts.append(tuple(postdev)) + else: + parts.append(FINAL_MARKER) + self.parts = tuple(parts) + if error_on_huge_major_num and self.parts[0][0] > 1980: + raise HugeMajorVersionNumError("huge major version number, %r, " + "which might cause future problems: %r" % (self.parts[0][0], s)) + + def _parse_numdots(self, s, full_ver_str, drop_trailing_zeros=True, + pad_zeros_length=0): + """Parse 'N.N.N' sequences, return a list of ints. + + @param s {str} 'N.N.N..." sequence to be parsed + @param full_ver_str {str} The full version string from which this + comes. Used for error strings. + @param drop_trailing_zeros {bool} Whether to drop trailing zeros + from the returned list. Default True. + @param pad_zeros_length {int} The length to which to pad the + returned list with zeros, if necessary. Default 0. + """ + nums = [] + for n in s.split("."): + if len(n) > 1 and n[0] == '0': + raise IrrationalVersionError("cannot have leading zero in " + "version number segment: '%s' in %r" % (n, full_ver_str)) + nums.append(int(n)) + if drop_trailing_zeros: + while nums and nums[-1] == 0: + nums.pop() + while len(nums) < pad_zeros_length: + nums.append(0) + return nums + + def __str__(self): + return self.parts_to_str(self.parts) + + @classmethod + def parts_to_str(cls, parts): + """Transforms a version expressed in tuple into its string + representation.""" + # XXX This doesn't check for invalid tuples + main, prerel, postdev = parts + s = '.'.join(str(v) for v in main) + if prerel is not FINAL_MARKER: + s += prerel[0] + s += '.'.join(str(v) for v in prerel[1:]) + if postdev and postdev is not FINAL_MARKER: + if postdev[0] == 'f': + postdev = postdev[1:] + i = 0 + while i < len(postdev): + if i % 2 == 0: + s += '.' + s += str(postdev[i]) + i += 1 + return s + + def __repr__(self): + return "%s('%s')" % (self.__class__.__name__, self) + + def _cannot_compare(self, other): + raise TypeError("cannot compare %s and %s" + % (type(self).__name__, type(other).__name__)) + + def __eq__(self, other): + if not isinstance(other, NormalizedVersion): + self._cannot_compare(other) + return self.parts == other.parts + + def __lt__(self, other): + if not isinstance(other, NormalizedVersion): + self._cannot_compare(other) + return self.parts < other.parts + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + return not (self.__lt__(other) or self.__eq__(other)) + + def __le__(self, other): + return self.__eq__(other) or self.__lt__(other) + + def __ge__(self, other): + return self.__eq__(other) or self.__gt__(other) + + +def suggest_normalized_version(s): + """Suggest a normalized version close to the given version string. + + If you have a version string that isn't rational (i.e. NormalizedVersion + doesn't like it) then you might be able to get an equivalent (or close) + rational version from this function. + + This does a number of simple normalizations to the given string, based + on observation of versions currently in use on PyPI. Given a dump of + those version during PyCon 2009, 4287 of them: + - 2312 (53.93%) match NormalizedVersion without change + - with the automatic suggestion + - 3474 (81.04%) match when using this suggestion method + + @param s {str} An irrational version string. + @returns A rational version string, or None, if couldn't determine one. + """ + try: + NormalizedVersion(s) + return s # already rational + except IrrationalVersionError: + pass + + rs = s.lower() + + # part of this could use maketrans + for orig, repl in (('-alpha', 'a'), ('-beta', 'b'), ('alpha', 'a'), + ('beta', 'b'), ('rc', 'c'), ('-final', ''), + ('-pre', 'c'), + ('-release', ''), ('.release', ''), ('-stable', ''), + ('+', '.'), ('_', '.'), (' ', ''), ('.final', ''), + ('final', '')): + rs = rs.replace(orig, repl) + + # if something ends with dev or pre, we add a 0 + rs = re.sub(r"pre$", r"pre0", rs) + rs = re.sub(r"dev$", r"dev0", rs) + + # if we have something like "b-2" or "a.2" at the end of the + # version, that is pobably beta, alpha, etc + # let's remove the dash or dot + rs = re.sub(r"([abc|rc])[\-\.](\d+)$", r"\1\2", rs) + + # 1.0-dev-r371 -> 1.0.dev371 + # 0.1-dev-r79 -> 0.1.dev79 + rs = re.sub(r"[\-\.](dev)[\-\.]?r?(\d+)$", r".\1\2", rs) + + # Clean: 2.0.a.3, 2.0.b1, 0.9.0~c1 + rs = re.sub(r"[.~]?([abc])\.?", r"\1", rs) + + # Clean: v0.3, v1.0 + if rs.startswith('v'): + rs = rs[1:] + + # Clean leading '0's on numbers. + # TODO: unintended side-effect on, e.g., "2003.05.09" + # PyPI stats: 77 (~2%) better + rs = re.sub(r"\b0+(\d+)(?!\d)", r"\1", rs) + + # Clean a/b/c with no version. E.g. "1.0a" -> "1.0a0". Setuptools infers + # zero. + # PyPI stats: 245 (7.56%) better + rs = re.sub(r"(\d+[abc])$", r"\g<1>0", rs) + + # the 'dev-rNNN' tag is a dev tag + rs = re.sub(r"\.?(dev-r|dev\.r)\.?(\d+)$", r".dev\2", rs) + + # clean the - when used as a pre delimiter + rs = re.sub(r"-(a|b|c)(\d+)$", r"\1\2", rs) + + # a terminal "dev" or "devel" can be changed into ".dev0" + rs = re.sub(r"[\.\-](dev|devel)$", r".dev0", rs) + + # a terminal "dev" can be changed into ".dev0" + rs = re.sub(r"(?![\.\-])dev$", r".dev0", rs) + + # a terminal "final" or "stable" can be removed + rs = re.sub(r"(final|stable)$", "", rs) + + # The 'r' and the '-' tags are post release tags + # 0.4a1.r10 -> 0.4a1.post10 + # 0.9.33-17222 -> 0.9.3.post17222 + # 0.9.33-r17222 -> 0.9.3.post17222 + rs = re.sub(r"\.?(r|-|-r)\.?(\d+)$", r".post\2", rs) + + # Clean 'r' instead of 'dev' usage: + # 0.9.33+r17222 -> 0.9.3.dev17222 + # 1.0dev123 -> 1.0.dev123 + # 1.0.git123 -> 1.0.dev123 + # 1.0.bzr123 -> 1.0.dev123 + # 0.1a0dev.123 -> 0.1a0.dev123 + # PyPI stats: ~150 (~4%) better + rs = re.sub(r"\.?(dev|git|bzr)\.?(\d+)$", r".dev\2", rs) + + # Clean '.pre' (normalized from '-pre' above) instead of 'c' usage: + # 0.2.pre1 -> 0.2c1 + # 0.2-c1 -> 0.2c1 + # 1.0preview123 -> 1.0c123 + # PyPI stats: ~21 (0.62%) better + rs = re.sub(r"\.?(pre|preview|-c)(\d+)$", r"c\g<2>", rs) + + # Tcl/Tk uses "px" for their post release markers + rs = re.sub(r"p(\d+)$", r".post\1", rs) + + try: + NormalizedVersion(rs) + return rs # already rational + except IrrationalVersionError: + pass + return None diff --git a/script.plexmod/lib/_included_packages/plexnet/video.py b/script.plexmod/lib/_included_packages/plexnet/video.py new file mode 100644 index 000000000..8adae39f7 --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/video.py @@ -0,0 +1,782 @@ +from __future__ import absolute_import +from functools import wraps + +from . import plexobjects +from . import media +from . import plexmedia +from . import plexstream +from . import exceptions +from . import compat +from . import plexlibrary +from . import util +from . import mediachoice + + +class PlexVideoItemList(plexobjects.PlexItemList): + def __init__(self, data, initpath=None, server=None, container=None): + self._data = data + self._initpath = initpath + self._server = server + self._container = container + self._items = None + + @property + def items(self): + if self._items is None: + if self._data is not None: + self._items = [plexobjects.buildItem(self._server, elem, self._initpath, container=self._container) for elem in self._data] + else: + self._items = [] + + return self._items + + +def forceMediaChoice(method): + @wraps(method) + def _impl(self, *method_args, **method_kwargs): + # set mediaChoice if we don't have any yet, or the one we have is incomplete and the new one isn't + media = method_kwargs.get("media", self.media()[0]) + partIndex = method_kwargs.get("partIndex", 0) + if not self.mediaChoice or (not self.mediaChoice.media.hasStreams() and media.hasStreams()): + self.setMediaChoice(media=media, partIndex=partIndex) + return method(self, *method_args, **method_kwargs) + return _impl + + +class Video(media.MediaItem): + TYPE = None + manually_selected_sub_stream = False + current_subtitle_is_embedded = False + _current_subtitle_idx = None + + def __init__(self, *args, **kwargs): + self._settings = None + media.MediaItem.__init__(self, *args, **kwargs) + + def __eq__(self, other): + return other and self.ratingKey == other.ratingKey + + def __ne__(self, other): + return not self.__eq__(other) + + @property + def settings(self): + if not self._settings: + from . import plexapp + self._settings = plexapp.PlayerSettingsInterface() + + return self._settings + + @settings.setter + def settings(self, value): + self._settings = value + + # overridden by Movie/Episode + @property + def subtitleStreams(self): + return [] + + @property + def videoStreams(self): + return [] + + @property + def audioStreams(self): + return [] + + def selectedVideoStream(self, fallback=False): + if self.videoStreams: + for stream in self.videoStreams: + if stream.isSelected(): + return stream + if fallback: + return self.videoStreams[0] + return None + + def selectedAudioStream(self, fallback=False): + if self.audioStreams: + for stream in self.audioStreams: + if stream.isSelected(): + return stream + if fallback: + return self.audioStreams[0] + return None + + def selectedSubtitleStream(self, forced_subtitles_override=False, fallback=False): + if self._current_subtitle_idx: + try: + return self.subtitleStreams[self._current_subtitle_idx] + except IndexError: + pass + + if self.subtitleStreams: + for stream in self.subtitleStreams: + if stream.isSelected(): + if forced_subtitles_override and \ + stream.forced.asBool() and self.manually_selected_sub_stream != stream.id: + # try finding a non-forced variant of this stream + possible_alt = None + for alt_stream in self.subtitleStreams: + if alt_stream.language == stream.language and alt_stream != stream \ + and not alt_stream.forced.asBool(): + if possible_alt and not possible_alt.key and alt_stream.key: + possible_alt = alt_stream + break + if not possible_alt: + possible_alt = alt_stream + if possible_alt: + util.DEBUG_LOG("Selecting stream %s instead of %s" % + (possible_alt, stream)) + stream.setSelected(False) + possible_alt.setSelected(True) + self.current_subtitle_is_embedded = possible_alt.embedded + if self._current_subtitle_idx != possible_alt.typeIndex: + self._current_subtitle_idx = possible_alt.typeIndex + return possible_alt + + if self._current_subtitle_idx != stream.typeIndex: + self._current_subtitle_idx = stream.typeIndex + self.current_subtitle_is_embedded = stream.embedded + return stream + if fallback: + stream = self.subtitleStreams[0] + if self._current_subtitle_idx != stream.typeIndex: + self._current_subtitle_idx = stream.typeIndex + return stream + return None + + def setMediaChoice(self, media=None, partIndex=0): + media = media or self.media()[0] + self.mediaChoice = mediachoice.MediaChoice(media, partIndex=partIndex) + + @forceMediaChoice + def selectStream(self, stream, _async=True): + self.mediaChoice.part.setSelectedStream(stream.streamType.asInt(), stream.id, _async) + # Update any affected streams + if stream.streamType.asInt() == plexstream.PlexStream.TYPE_AUDIO: + for audioStream in self.audioStreams: + if audioStream.id == stream.id: + audioStream.setSelected(True) + elif audioStream.isSelected(): + audioStream.setSelected(False) + elif stream.streamType.asInt() == plexstream.PlexStream.TYPE_SUBTITLE: + self._current_subtitle_idx = None + self.current_subtitle_is_embedded = False + for subtitleStream in self.subtitleStreams: + if subtitleStream.id == stream.id: + subtitleStream.setSelected(True) + self.current_subtitle_is_embedded = subtitleStream.embedded + self._current_subtitle_idx = subtitleStream.typeIndex + elif subtitleStream.isSelected(): + subtitleStream.setSelected(False) + + @forceMediaChoice + def cycleSubtitles(self, forward=True): + amount = len(self.subtitleStreams) + if not amount: + return False + cur = self.selectedSubtitleStream() + if not cur: + # use fallback + stream = self.selectedSubtitleStream(fallback=True) + else: + # set next if we're not at the end of the list + if forward: + if cur.typeIndex < len(self.subtitleStreams) - 1: + stream = self.subtitleStreams[cur.typeIndex+1] + else: + stream = self.subtitleStreams[0] + else: + if cur.typeIndex > 0: + stream = self.subtitleStreams[cur.typeIndex - 1] + else: + stream = self.subtitleStreams[-1] + + util.DEBUG_LOG("Selecting subtitle stream: {} (was: {})".format(stream, cur)) + self.selectStream(stream) + return stream + + @forceMediaChoice + def disableSubtitles(self): + self.selectStream(plexstream.NONE_STREAM) + + @property + def hasSubtitle(self): + return bool(self.selectedSubtitleStream()) + + @property + def hasSubtitles(self): + return bool(self.subtitleStreams) + + def isVideoItem(self): + return True + + @forceMediaChoice + def _findStreams(self, streamtype, withMC=True): + idx = 0 + streams = [] + source = [self.mediaChoice.media] if withMC else self.media() + for media_ in source: + parts = [self.mediaChoice.part] if withMC else media_.parts + for part in parts: + for stream in part.streams: + if stream.streamType.asInt() == streamtype: + stream.typeIndex = idx + streams.append(stream) + idx += 1 + return streams + + def analyze(self): + """ The primary purpose of media analysis is to gather information about that media + item. All of the media you add to a Library has properties that are useful to + know - whether it's a video file, a music track, or one of your photos. + """ + self.server.query('/%s/analyze' % self.key) + + def markWatched(self, **kwargs): + path = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey + self.server.query(path) + self.reload(**kwargs) + + def markUnwatched(self, **kwargs): + path = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey + self.server.query(path) + self.reload(**kwargs) + + # def play(self, client): + # client.playMedia(self) + + def refresh(self): + self.server.query('%s/refresh' % self.key, method=self.server.session.put) + + def _getStreamURL(self, **params): + if self.TYPE not in ('movie', 'episode', 'track'): + raise exceptions.Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE) + mvb = params.get('maxVideoBitrate') + vr = params.get('videoResolution') + + # import plexapp + + params = { + 'path': self.key, + 'offset': params.get('offset', 0), + 'copyts': params.get('copyts', 1), + 'protocol': params.get('protocol', 'hls'), + 'mediaIndex': params.get('mediaIndex', 0), + 'directStream': '1', + 'directPlay': '0', + 'X-Plex-Platform': params.get('platform', ''), + # 'X-Plex-Platform': params.get('platform', util.INTERFACE.getGlobal('platform')), + 'maxVideoBitrate': max(mvb, 64) if mvb else None, + 'videoResolution': '{0}x{1}'.format(*vr) if vr else None + } + + final = {} + + for k, v in params.items(): + if v is not None: # remove None values + final[k] = v + + streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video' + server = self.getTranscodeServer(True, self.TYPE) + + return server.buildUrl('/{0}/:/transcode/universal/start.m3u8?{1}'.format(streamtype, compat.urlencode(final)), includeToken=True) + # path = "/video/:/transcode/universal/" + command + "?session=" + AppSettings().GetGlobal("clientIdentifier") + + @forceMediaChoice + def resolutionString(self): + res = self.mediaChoice.media.videoResolution + if not res: + return '' + + if res.isdigit(): + return '{0}p'.format(self.mediaChoice.media.videoResolution) + else: + return res.upper() + + @forceMediaChoice + def audioCodecString(self): + codec = (self.mediaChoice.media.audioCodec or '').lower() + + if codec in ('dca', 'dca-ma', 'dts-hd', 'dts-es', 'dts-hra'): + codec = "DTS" + else: + codec = codec.upper() + + return codec + + @forceMediaChoice + def videoCodecString(self): + return (self.mediaChoice.media.videoCodec or '').upper() + + @property + @forceMediaChoice + def videoCodecRendering(self): + stream = self.mediaChoice.videoStream + + if not stream: + return '' + + return stream.videoCodecRendering + + @forceMediaChoice + def audioChannelsString(self, translate_func=util.dummyTranslate): + channels = self.mediaChoice.media.audioChannels.asInt() + + if channels == 1: + return translate_func("Mono") + elif channels == 2: + return translate_func("Stereo") + elif channels > 0: + return "{0}.1".format(channels - 1) + else: + return "" + + @property + def remainingTime(self): + if not self.viewOffset.asInt(): + return + return (self.duration.asInt() - self.viewOffset.asInt()) // 1000 + + @property + def remainingTimeString(self): + if not self.remainingTime: + return '' + seconds = self.remainingTime + hours = seconds // 3600 + minutes = (seconds - hours * 3600) // 60 + return (hours and "{}h ".format(hours) or '') + (minutes and "{}m".format(minutes) or "0m") + + def available(self): + return any(v.isAccessible() for v in self.media()) + + +class RelatedMixin(object): + _relatedCount = None + + @property + def relatedCount(self): + if self._relatedCount is None: + related = self.getRelated(0, 0) + if related is not None: + self._relatedCount = related.totalSize + else: + self._relatedCount = 0 + + return self._relatedCount + + @property + def related(self): + return self.getRelated(0, 8) + + def getRelated(self, offset=None, limit=None, _max=36): + path = '/library/metadata/%s/similar' % self.ratingKey + try: + return plexobjects.listItems(self.server, path, offset=offset, limit=limit, params={"count": _max}) + except exceptions.BadRequest: + util.DEBUG_LOG("Invalid related items response returned for %s" % self) + return None + + +class SectionOnDeckMixin(object): + _sectionOnDeckCount = None + + def sectionOnDeck(self, offset=None, limit=None): + query = '/library/sections/{0}/onDeck'.format(self.getLibrarySectionId()) + return plexobjects.listItems(self.server, query, offset=offset, limit=limit) + + @property + def sectionOnDeckCount(self): + if self._sectionOnDeckCount is None: + self._sectionOnDeckCount = self.sectionOnDeck(0, 0).totalSize + + return self._sectionOnDeckCount + + +class PlayableVideo(Video, RelatedMixin): + TYPE = None + _videoStreams = None + _audioStreams = None + _subtitleStreams = None + _current_subtitle_idx = None + + def _setData(self, data): + Video._setData(self, data) + if self.isFullObject(): + self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self) + self.chapters = plexobjects.PlexItemList(data, media.Chapter, media.Chapter.TYPE, server=self.server) + + self.resetStreams() + + def setMediaChoice(self, *args, **kwargs): + """ + Reset cached streams after setting a mediaChoice + """ + super(PlayableVideo, self).setMediaChoice(*args, **kwargs) + self.resetStreams() + + def resetStreams(self): + self._videoStreams = None + self._audioStreams = None + self._subtitleStreams = None + + def reload(self, *args, **kwargs): + if not kwargs.get('_soft'): + if self.get('viewCount'): + del self.viewCount + if self.get('viewOffset'): + del self.viewOffset + + fromMediaChoice = kwargs.get("fromMediaChoice", False) + + # capture current IDs + mediaID = None + partID = None + streamIDs = None + reSelect = False + if fromMediaChoice and self.mediaChoice: + reSelect = True + mediaID = self.mediaChoice.media.id + partID = self.mediaChoice.part.id + streamIDs = [] + if self.mediaChoice.media.hasStreams(): + subtitleStream = self.selectedSubtitleStream(fallback=False) + streamIDs = [self.selectedVideoStream(fallback=True).id, + self.selectedAudioStream(fallback=True).id] + if subtitleStream: + streamIDs.append(subtitleStream.id) + + Video.reload(self, *args, **kwargs) + + # re-select selected IDs + if reSelect: + selMedia = None + selPartIndex = 0 + for media in self.media: + if media.id == mediaID: + selMedia = media + media.set('selected', '1') + for index, part in enumerate(media.parts): + if part.id == partID: + selPartIndex = index + for stream in part.streams: + if stream.id in streamIDs: + stream.setSelected(True) + self.mediaChoice = mediachoice.MediaChoice(selMedia, partIndex=selPartIndex) + + return self + + def postPlay(self, **params): + query = '/hubs/metadata/{0}/postplay'.format(self.ratingKey) + data = self.server.query(query, params=params) + container = plexobjects.PlexContainer(data, initpath=query, server=self.server, address=query) + + hubs = {} + + for elem in data: + hub = plexlibrary.Hub(elem, server=self.server, container=container) + hubs[hub.hubIdentifier] = hub + return hubs + + +@plexobjects.registerLibType +class Movie(PlayableVideo): + TYPE = 'movie' + + def _setData(self, data): + PlayableVideo._setData(self, data) + if self.isFullObject(): + self.collections = plexobjects.PlexItemList(data, media.Collection, media.Collection.TYPE, + server=self.server) + self.countries = plexobjects.PlexItemList(data, media.Country, media.Country.TYPE, server=self.server) + self.directors = plexobjects.PlexItemList(data, media.Director, media.Director.TYPE, server=self.server) + self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server) + self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, + initpath=self.initpath, server=self.server, media=self) + self.producers = plexobjects.PlexItemList(data, media.Producer, media.Producer.TYPE, server=self.server) + self.roles = plexobjects.PlexItemList(data, media.Role, media.Role.TYPE, server=self.server, + container=self.container) + self.reviews = plexobjects.PlexItemList(data, media.Review, media.Review.TYPE, server=self.server, + container=self.container) + self.writers = plexobjects.PlexItemList(data, media.Writer, media.Writer.TYPE, server=self.server) + #self.related = plexobjects.PlexItemList(data.find('Related'), plexlibrary.Hub, plexlibrary.Hub.TYPE, server=self.server, container=self) + else: + if data.find(media.Media.TYPE) is not None: + self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self) + + self.markers = plexobjects.PlexItemList(data, media.Marker, media.Marker.TYPE, server=self.server) + + # data for active sessions + self.sessionKey = plexobjects.PlexValue(data.attrib.get('sessionKey', ''), self) + self.user = self._findUser(data) + self.player = self._findPlayer(data) + self.session = self._findSession(data) + self.transcodeSession = self._findTranscodeSession(data) + + @property + def defaultTitle(self): + title = self.title or '' + if self.editionTitle: + title = title + " \u2022 " + self.editionTitle + return title + + @property + def maxHeight(self): + height = 0 + for m in self.media: + if m.height.asInt() > height: + height = m.height.asInt() + return height + + @property + def videoStreams(self): + if self._videoStreams is None: + self._videoStreams = self._findStreams(plexstream.PlexStream.TYPE_VIDEO) + return self._videoStreams + + @property + def audioStreams(self): + if self._audioStreams is None: + self._audioStreams = self._findStreams(plexstream.PlexStream.TYPE_AUDIO) + return self._audioStreams + + @property + def subtitleStreams(self): + if self._subtitleStreams is None: + self._subtitleStreams = self._findStreams(plexstream.PlexStream.TYPE_SUBTITLE) + return self._subtitleStreams + + @property + def actors(self): + return self.roles + + @property + def isWatched(self): + return self.get('viewCount').asInt() > 0 or self.get('viewOffset').asInt() > 0 + + def getStreamURL(self, **params): + return self._getStreamURL(**params) + + +@plexobjects.registerLibType +class Show(Video, RelatedMixin, SectionOnDeckMixin): + TYPE = 'show' + + def _setData(self, data): + Video._setData(self, data) + if self.isFullObject(): + self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server) + self.roles = plexobjects.PlexItemList(data, media.Role, media.Role.TYPE, server=self.server, container=self.container) + #self.related = plexobjects.PlexItemList(data.find('Related'), plexlibrary.Hub, plexlibrary.Hub.TYPE, server=self.server, container=self) + self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self) + + @property + def unViewedLeafCount(self): + return self.leafCount.asInt() - self.viewedLeafCount.asInt() + + @property + def isWatched(self): + return self.viewedLeafCount == self.leafCount + + @property + def playbackSettings(self): + return util.INTERFACE.playbackManager(self) + + def seasons(self): + path = self.key + return plexobjects.listItems(self.server, path, Season.TYPE) + + def season(self, title): + path = self.key + return plexobjects.findItem(self.server, path, title) + + def episodes(self, watched=None, offset=None, limit=None): + leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey + return plexobjects.listItems(self.server, leavesKey, watched=watched, offset=offset, limit=limit) + + def episode(self, title): + path = '/library/metadata/%s/allLeaves' % self.ratingKey + return plexobjects.findItem(self.server, path, title) + + def all(self): + return self.episodes() + + def watched(self): + return self.episodes(watched=True) + + def unwatched(self): + return self.episodes(watched=False) + + def refresh(self): + self.server.query('/library/metadata/%s/refresh' % self.ratingKey) + + +@plexobjects.registerLibType +class Season(Video): + TYPE = 'season' + + def _setData(self, data): + Video._setData(self, data) + if self.isFullObject(): + self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self) + + @property + def defaultTitle(self): + return self.parentTitle or self.title + + @property + def unViewedLeafCount(self): + return self.leafCount.asInt() - self.viewedLeafCount.asInt() + + @property + def isWatched(self): + return self.viewedLeafCount == self.leafCount + + def episodes(self, watched=None, offset=None, limit=None): + path = self.key + return plexobjects.listItems(self.server, path, watched=watched, offset=offset, limit=limit) + + def episode(self, title): + path = self.key + return plexobjects.findItem(self.server, path, title) + + def all(self): + return self.episodes() + + def show(self): + return plexobjects.listItems(self.server, self.parentKey)[0] + + def watched(self): + return self.episodes(watched=True) + + def unwatched(self): + return self.episodes(watched=False) + + +@plexobjects.registerLibType +class Episode(PlayableVideo, SectionOnDeckMixin): + TYPE = 'episode' + + def init(self, data): + self._show = None + self._season = None + + def _setData(self, data): + PlayableVideo._setData(self, data) + if self.isFullObject(): + self.directors = plexobjects.PlexItemList(data, media.Director, media.Director.TYPE, server=self.server) + self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self) + self.writers = plexobjects.PlexItemList(data, media.Writer, media.Writer.TYPE, server=self.server) + else: + if data.find(media.Media.TYPE) is not None: + self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self) + + self.markers = plexobjects.PlexItemList(data, media.Marker, media.Marker.TYPE, server=self.server) + + # data for active sessions + self.sessionKey = plexobjects.PlexValue(data.attrib.get('sessionKey', ''), self) + self.user = self._findUser(data) + self.player = self._findPlayer(data) + self.session = self._findSession(data) + self.transcodeSession = self._findTranscodeSession(data) + + @property + def defaultTitle(self): + return self.grandparentTitle or self.parentTitle or self.title + + @property + def defaultThumb(self): + return self.grandparentThumb or self.parentThumb or self.thumb + + @property + def videoStreams(self): + if self._videoStreams is None: + self._videoStreams = self._findStreams(plexstream.PlexStream.TYPE_VIDEO) + return self._videoStreams + + @property + def audioStreams(self): + if self._audioStreams is None: + self._audioStreams = self._findStreams(plexstream.PlexStream.TYPE_AUDIO) + return self._audioStreams + + @property + def subtitleStreams(self): + if self._subtitleStreams is None: + self._subtitleStreams = self._findStreams(plexstream.PlexStream.TYPE_SUBTITLE) + return self._subtitleStreams + + @property + def isWatched(self): + return self.get('viewCount').asInt() > 0 or self.get('viewOffset').asInt() > 0 + + @property + def playbackSettings(self): + return self.show().playbackSettings + + def getStreamURL(self, **params): + return self._getStreamURL(**params) + + def season(self): + skipParent = self.get('skipParent').asBool() + key = self.parentKey if not skipParent else self.grandparentKey + if not self._season: + items = plexobjects.listItems(self.server, key) + + if items: + self._season = items[0] + return self._season + + def show(self): + if not self._show: + self._show = plexobjects.listItems(self.server, self.grandparentKey)[0] + return self._show + + @property + def genres(self): + return self.show().genres + + @property + def roles(self): + return self.show().roles + + def getRelated(self, offset=None, limit=None, _max=36): + return self.show().getRelated(offset=offset, limit=limit, _max=_max) + + +@plexobjects.registerLibType +class Clip(PlayableVideo): + TYPE = 'clip' + + def _setData(self, data): + PlayableVideo._setData(self, data) + if self.isFullObject(): + self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self) + else: + if data.find(media.Media.TYPE) is not None: + self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self) + + @property + def isWatched(self): + return self.get('viewCount').asInt() > 0 or self.get('viewOffset').asInt() > 0 + + def getStreamURL(self, **params): + return self._getStreamURL(**params) + + @property + def videoStreams(self): + if self._videoStreams is None: + self._videoStreams = self._findStreams(plexstream.PlexStream.TYPE_VIDEO) + return self._videoStreams + + @property + def audioStreams(self): + if self._audioStreams is None: + self._audioStreams = self._findStreams(plexstream.PlexStream.TYPE_AUDIO) + return self._audioStreams + + @property + def subtitleStreams(self): + if self._subtitleStreams is None: + self._subtitleStreams = self._findStreams(plexstream.PlexStream.TYPE_SUBTITLE) + return self._subtitleStreams diff --git a/script.plexmod/lib/_included_packages/plexnet/videosession.py b/script.plexmod/lib/_included_packages/plexnet/videosession.py new file mode 100644 index 000000000..933dac0ed --- /dev/null +++ b/script.plexmod/lib/_included_packages/plexnet/videosession.py @@ -0,0 +1,416 @@ +# coding=utf-8 +import six +from collections import OrderedDict +from plexnet import plexapp + + +class MediaDetails: + """ + Gathers attributes from a MediaContainer + """ + details = None + attribute_map = { + "container": "part.attrib_container", + "partDecision": "part.decision", + "videoResolution": "media.videoResolution", + "videoBitrate": "video_stream.bitrate", + "videoCodec": "video_stream.codec", + "videoStreamDecision": "video_stream.decision", + "transcodeVideoDecision": "transcode_session.videoDecision", + "transcodeHWEncoding": "transcode_session.transcodeHwEncoding", + "audioCodec": "audio_stream.codec", + "audioBitrate": "audio_stream.bitrate", + "audioChannels": "audio_stream.channels", + "audioStreamDecision": "audio_stream.decision", + "subtitleCodec": "subtitle_stream.codec", + "subtitleStreamDecision": "subtitle_stream.decision", + "subtitleLocation": "subtitle_stream.location", + "subtitleBurn": "subtitle_stream.burn", + } + + def __init__(self, *args, **kwargs): + self.details = self.findMediaDetails(*args, **kwargs) + + def attributesFromInstance(self, map, reference_data): + # gather attribute values + final_data = {} + for attribute, dataPath in six.iteritems(map): + objName, attribName = dataPath.split(".") + if objName in reference_data: + obj = reference_data[objName] + final_data[attribute] = getattr(obj, attribName, None) or None + return final_data + + def findMediaDetails(self, mediaContainer, mediaChoice, transcodeSession=None): + """ + + """ + reference_data = { + "media": None, + "part": None, + "video_stream": None, + "audio_stream": None, + "subtitle_stream": None, + "transcode_session": transcodeSession or mediaContainer.transcodeSession + } + + # We can't use mediaChoice here directly, because it doesn't necessarily hold the newest data (in case of + # an actual MediaContainer from the Session endpoint that data is king) + # Instead find the media/part/streams which were selected and are held in MediaChoice inside the current + # mediaContainer + for media in mediaContainer.media: + if media.id == mediaChoice.media.id: + reference_data["media"] = media + + for part in media.parts: + if part.id == mediaChoice.part.id: + reference_data["part"] = part + + for stream in part.streams: + if mediaChoice.videoStream != None and stream.id == mediaChoice.videoStream.id: + reference_data["video_stream"] = stream + elif mediaChoice.audioStream != None and stream.id == mediaChoice.audioStream.id: + reference_data["audio_stream"] = stream + elif mediaChoice.subtitleStream != None and stream.id == mediaChoice.subtitleStream.id: + reference_data["subtitle_stream"] = stream + + final_data = { + "hasVideoStream": bool(reference_data["video_stream"]), + "hasAudioStream": bool(reference_data["audio_stream"]), + "hasSubtitleStream": bool(reference_data["subtitle_stream"]), + } + final_data.update(self.attributesFromInstance(self.attribute_map, reference_data)) + + del reference_data + + return final_data + + def __getattr__(self, item): + if self.details and item in self.details: + return self.details[item] + raise AttributeError("%r object has no attribute %r" % (self.__class__, item)) + + +class MediaDetailsIncomplete(MediaDetails): + """ + Gathers attributes from a TranscodeSession + """ + + incompleteAttribMap = { + "container": "transcode_session.attrib_container", + "videoCodec": "transcode_session.videoCodec", + "videoStreamDecision": "transcode_session.videoDecision", + "transcodeVideoDecision": "transcode_session.videoDecision", + "audioCodec": "transcode_session.audioCodec", + "audioChannels": "transcode_session.audioChannels", + "audioStreamDecision": "transcode_session.audioDecision", + "subtitleStreamDecision": "transcode_session.subtitleDecision", + } + + def findMediaDetails(self, mediaContainer, mediaChoice, incompleteSessionData=None): + transcodeSession = mediaContainer._findTranscodeSession(incompleteSessionData) + + # get base data from original mediaChoice media instance + data = MediaDetails.findMediaDetails(self, mediaContainer, mediaChoice, transcodeSession=transcodeSession) + + decision = "directplay" + bandwidths = mediaContainer._findBandwidths(incompleteSessionData) + + if transcodeSession: + decision = "transcode" + + data.update(self.attributesFromInstance(self.incompleteAttribMap, {"transcode_session": transcodeSession})) + + # fill remaining data + + for bw in bandwidths: + if bw.resolution: + data["videoResolution"] = bw.resolution + break + + if data["hasVideoStream"]: + # sadly we don't know the final bitrate for the video/audio streams with incomplete data + data["videoBitrate"] = "?" + + if data["hasAudioStream"]: + data["audioBitrate"] = "?" + + if data["hasSubtitleStream"]: + if data["subtitleStreamDecision"] == "burn": + data["subtitleBurn"] = True + data["subtitleCodec"] = "burn" + + data["partDecision"] = decision + + return data + + +class MediaDetailsHolder: + """ + Holds information about the currently selected MediaContainer (self.original) and the currently playing + MediaContainer (self.session) + """ + session = None + original = None + + def __init__(self, originalMedia, sessionMedia, mediaChoice, incompleteSessionData=None): + if incompleteSessionData: + self.session = MediaDetailsIncomplete(originalMedia, mediaChoice, + incompleteSessionData=incompleteSessionData) + else: + self.session = MediaDetails(sessionMedia, mediaChoice) + self.original = MediaDetails(originalMedia, mediaChoice) + + +ATTRIBUTE_TYPES = OrderedDict() + + +def registerAttributeType(cls): + ATTRIBUTE_TYPES[cls.name] = cls + return cls + + +def normRes(res): + try: + int(res) + except: + pass + else: + res += "p" + return res + + +class DPAttribute: + """ + An attribute reference to source.attr + """ + def __init__(self, attr, source="details.original"): + self.attr = attr + self.attrWithPath = "%s.%s" % (source, attr) if source else attr + + def __call__(self, *args, **kwargs): + return self.value(*args, **kwargs) + + def resolve(self, instance_or_value, obj): + """ + Resolve attribute to value based on the given type. + Returns value or Attribute.value() + + :param instance_or_value: Attribute instance or value + :param obj: VideoSessionInfo instance + :return: + """ + return instance_or_value.value(obj) if isinstance(instance_or_value, DPAttribute) else instance_or_value + + def value(self, obj): + """ + Returns value of the given path based on obj + + :param obj: VideoSessionInfo instance + :return: + """ + o = obj + for p in self.attrWithPath.split("."): + o = getattr(o, p, None) + + return o + + +class DPAttributeOriginal(DPAttribute): + pass + + +class DPAttributeSession(DPAttribute): + def __init__(self, attr): + DPAttribute.__init__(self, attr, source="details.session") + + +class DPAttributesDiffer(DPAttribute): + def __init__(self, attr, formatTrue=u"%(val1)s->%(val2)s", formatFalse=u"%(val1)s", + valueFormatter=lambda i, v1, v2: [v1, v2]): + DPAttribute.__init__(self, attr) + self.formatTrue = formatTrue + self.formatFalse = formatFalse + self.valueFormatter = valueFormatter + + def value(self, obj): + """ + Returns formatted value if values differ, otherwise the original value based on attr + + :param obj: VideoSessionInfo instance + :return: + """ + val1 = getattr(obj.details.original, self.attr, None) + val2 = getattr(obj.details.session, self.attr, None) + formatted_val1, formatted_val2 = self.valueFormatter(obj, val1, val2) + + if formatted_val2 and formatted_val1 != formatted_val2: + return self.formatTrue % {"val1": formatted_val1, "val2": formatted_val2} + if not formatted_val1: + return "" + return (self.formatFalse % {"val1": formatted_val1, "val2": formatted_val2}) if self.formatFalse else formatted_val1 + + +class DPAttributeExists(DPAttribute): + def __init__(self, attr, source="details.session", returnValue=None): + DPAttribute.__init__(self, attr, source=source) + self.returnValue = returnValue + + def value(self, obj): + """ + Returns returnValue, which may also be an Attribute instance, in case attr exists on obj + + :param obj: VideoSessionInfo instance + :return: + """ + result = DPAttribute.value(self, obj) + if self.returnValue and result: + return self.resolve(self.returnValue, obj) + + return result + + +class DPAttributeEqualsValue(DPAttribute): + def __init__(self, attr, compareTo, retVal, source="details.session"): + DPAttribute.__init__(self, attr, source=source) + self.compareTo = compareTo + self.retVal = retVal + + def value(self, obj): + """ + Returns retVal, which may also be an Attribute, if attr's value equals compareTo's value. compareTo may also + be an Attribute. + + :param obj: VideoSessionInfo instance + :return: + """ + result = DPAttribute.value(self, obj) + if result == self.resolve(self.compareTo, obj): + return self.resolve(self.retVal, obj) + + +class ComputedPPIValue: + """ + Holds the final computed attribute data for display + """ + name = None + data = None + displayCondition = None + dataPoints = [] + + @property + def label(self): + return self.name + + @property + def value(self): + return ", ".join([x for x in self.data if x not in (None, "")]) + + def __str__(self): + return "%s: %s" % (self.label, self.value) + + def __repr__(self): + return str(self) + + +@registerAttributeType +class ModePPI(ComputedPPIValue): + name = "Mode" + dataPoints = [ + DPAttributeSession("partDecision"), + DPAttributeExists("local", source="session.player", returnValue="local") + ] + + +@registerAttributeType +class ContainerPPI(ComputedPPIValue): + name = "Container" + dataPoints = [ + DPAttributesDiffer("container"), + ] + + +@registerAttributeType +class VideoPPI(ComputedPPIValue): + name = "Video" + displayCondition = DPAttributeExists("hasVideoStream", source="details.original") + dataPoints = [ + DPAttributesDiffer("videoCodec"), + DPAttributesDiffer("videoResolution", valueFormatter=lambda i, v1, v2: [normRes(v1), normRes(v2)]), + DPAttributesDiffer("videoBitrate", formatTrue=u"%(val1)s->%(val2)skbit", formatFalse=u"%(val1)skbit"), + lambda i: [ + (i.details.session.videoStreamDecision + " HW") + if i.details.session.transcodeVideoDecision == "transcode" and i.details.session.transcodeHWEncoding + else i.details.session.videoStreamDecision + ] + ] + + +@registerAttributeType +class AudioPPI(ComputedPPIValue): + name = "Audio" + displayCondition = DPAttributeExists("hasAudioStream", source="details.original") + dataPoints = [ + DPAttributesDiffer("audioCodec"), + DPAttributesDiffer("audioBitrate", formatTrue=u"%(val1)s->%(val2)skbit", formatFalse=u"%(val1)skbit"), + DPAttributesDiffer("audioChannels", formatTrue=u"%(val1)s->%(val2)sch", formatFalse=u"%(val1)sch"), + DPAttributeExists("audioStreamDecision") + ] + + +@registerAttributeType +class SubtitlesPPI(ComputedPPIValue): + name = "Subtitles" + displayCondition = DPAttributeExists("hasSubtitleStream", source="details.original") + dataPoints = [ + DPAttributesDiffer("subtitleCodec", valueFormatter=lambda i, v1, v2: [v1, + "burn" if i.details.session.subtitleBurn else v2]), + DPAttributeEqualsValue("subtitleStreamDecision", "burn", DPAttribute("subtitleStreamDecision")), + DPAttributeExists("subtitleLocation") + ] + + +@registerAttributeType +class UserPPI(ComputedPPIValue): + name = "User" + dataPoints = [ + lambda i: [u"%s @ %s" % (plexapp.ACCOUNT.title or plexapp.ACCOUNT.username or ' ', i.mediaItem.server.name)] + ] + + +class SessionAttributes(OrderedDict): + """ + Computes all the PPI instances' values + """ + def __init__(self, ref, *args, **kwargs): + self.ref = ref + OrderedDict.__init__(self, *args, **kwargs) + + for name, cls in six.iteritems(ATTRIBUTE_TYPES): + self[name] = instance = cls() + instance.data = [] + if not instance.displayCondition or instance.displayCondition(self.ref): + for dp in instance.dataPoints: + try: + # dataPoint may be a lambda or a DataPoint instance + result = dp(self.ref) + + # result may be list or value + if result is not None: + if isinstance(result, list): + instance.data += result + else: + instance.data.append(result) + except: + pass + + +class VideoSessionInfo: + def __init__(self, sessionMediaContainer, mediaContainer, incompleteSessionData=False): + self.mediaItem = mediaContainer + self.session = sessionMediaContainer + self.details = MediaDetailsHolder(self.mediaItem, self.session, mediaContainer.mediaChoice, + incompleteSessionData=incompleteSessionData) + + self.attributes = SessionAttributes(self) + diff --git a/script.plexmod/lib/backgroundthread.py b/script.plexmod/lib/backgroundthread.py new file mode 100644 index 000000000..7b69cd09d --- /dev/null +++ b/script.plexmod/lib/backgroundthread.py @@ -0,0 +1,255 @@ +from __future__ import absolute_import +import six.moves.queue +import heapq +from kodi_six import xbmc +from . import util +from plexnet import threadutils +from six.moves import range + + +class Tasks(list): + def add(self, task): + for t in self: + if not t.isValid(): + self.remove(t) + + if isinstance(task, list): + self += task + else: + self.append(task) + + def cancel(self): + while self: + self.pop().cancel() + + def kill(self): + self.cancel() + BGThreader.kill() + + +class Task: + def __init__(self, priority=None): + self._priority = priority + self._canceled = False + self.finished = False + + def __cmp__(self, other): + return self._priority - other._priority + + def __le__(self, other): + return self._priority < other._priority + + def __gt__(self, other): + return self._priority > other._priority + + def start(self): + BGThreader.addTask(self) + + def _run(self): + self.run() + self.finished = True + + def run(self): + pass + + def cancel(self): + self._canceled = True + + def isCanceled(self): + return self._canceled or util.MONITOR.abortRequested() + + def isValid(self): + return not self.finished and not self._canceled + + +class MutablePriorityQueue(six.moves.queue.PriorityQueue): + def _get(self, heappop=heapq.heappop): + self.queue.sort() + return heappop(self.queue) + + def lowest(self): + """Return the lowest priority item in the queue (not reliable!).""" + self.mutex.acquire() + try: + lowest = self.queue and min(self.queue) or None + except: + lowest = None + util.ERROR() + finally: + self.mutex.release() + return lowest + + +class BackgroundWorker: + def __init__(self, queue, name=None): + self._queue = queue + self.name = name + self._thread = None + self._abort = False + self._task = None + + def _runTask(self, task): + if task._canceled: + return + try: + task._run() + except: + util.ERROR() + + def abort(self): + self._abort = True + return self + + def aborted(self): + return self._abort or util.MONITOR.abortRequested() + + def start(self): + if self._thread and self._thread.is_alive(): + return + + self._thread = threadutils.KillableThread(target=self._queueLoop, name='BACKGROUND-WORKER({0})'.format(self.name)) + self._thread.start() + + def _queueLoop(self): + if self._queue.empty(): + return + + util.DEBUG_LOG('BGThreader: ({0}): Active'.format(self.name)) + try: + while not self.aborted(): + self._task = self._queue.get_nowait() + self._runTask(self._task) + self._queue.task_done() + self._task = None + except six.moves.queue.Empty: + util.DEBUG_LOG('BGThreader ({0}): Idle'.format(self.name)) + + def shutdown(self): + self.abort() + + if self._task: + self._task.cancel() + + if self._thread and self._thread.is_alive(): + util.DEBUG_LOG('BGThreader: thread ({0}): Waiting...'.format(self.name)) + self._thread.join() + util.DEBUG_LOG('BGThreader: thread ({0}): Done'.format(self.name)) + + def working(self): + return self._thread and self._thread.is_alive() + + def kill(self): + if self._thread and self._thread.is_alive(): + util.DEBUG_LOG('BGThreader: thread ({0}): Waiting...'.format(self.name)) + self._thread.join() + util.DEBUG_LOG('BGThreader: thread ({0}): Done'.format(self.name)) + + +class BackgroundThreader: + def __init__(self, name=None, worker_count=3): + self.name = name + self._queue = MutablePriorityQueue() + self._abort = False + self._priority = -1 + self.workers = [BackgroundWorker(self._queue, 'queue.{0}:worker.{1}'.format(self.name, x)) for x in range(worker_count)] + + def _nextPriority(self): + self._priority += 1 + return self._priority + + def abort(self): + self._abort = True + for w in self.workers: + w.abort() + return self + + def aborted(self): + return self._abort or util.MONITOR.abortRequested() + + def shutdown(self): + self.abort() + + for w in self.workers: + w.shutdown() + + def addTask(self, task): + task._priority = self._nextPriority() + self._queue.put(task) + self.startWorkers() + + def addTasks(self, tasks): + for t in tasks: + t._priority = self._nextPriority() + self._queue.put(t) + + self.startWorkers() + + def addTasksToFront(self, tasks): + lowest = self.getLowestPrority() + if lowest is None: + return self.addTasks(tasks) + + p = lowest - len(tasks) + for t in tasks: + t._priority = p + self._queue.put(t) + p += 1 + + self.startWorkers() + + def startWorkers(self): + for w in self.workers: + w.start() + + def working(self): + return not self._queue.empty() or self.hasTask() + + def hasTask(self): + return any([w.working() for w in self.workers]) + + def getLowestPrority(self): + lowest = self._queue.lowest() + if not lowest: + return None + + return lowest._priority + + def moveToFront(self, qitem): + lowest = self.getLowestPrority() + if lowest is None: + return + + qitem._priority = lowest - 1 + + def kill(self): + for w in self.workers: + w.kill() + + +class ThreaderManager: + def __init__(self): + self.index = 0 + self.abandoned = [] + self.threader = BackgroundThreader(str(self.index)) + + def __getattr__(self, name): + return getattr(self.threader, name) + + def reset(self): + if self.threader._queue.empty() and not self.threader.hasTask(): + return + + self.index += 1 + self.abandoned.append(self.threader.abort()) + self.threader = BackgroundThreader(str(self.index)) + + def shutdown(self): + self.threader.shutdown() + for a in self.abandoned: + a.shutdown() + + def kill(self): + self.threader.kill() + + +BGThreader = ThreaderManager() diff --git a/script.plexmod/lib/colors.py b/script.plexmod/lib/colors.py new file mode 100644 index 000000000..92d8d49df --- /dev/null +++ b/script.plexmod/lib/colors.py @@ -0,0 +1,79 @@ +# Constants +Background = 'FF111111' +BackgroundDark = 'FF0A0A0A' + +# OverlayVeryDark = GetAlpha(FF000000, 90), +# OverlayDark = GetAlpha(FF000000, 70), +# OverlayMed = GetAlpha(FF000000, 50), +# OverlayLht = GetAlpha(FF000000, 35), + +Border = 'FF1F1F1F' +Empty = 'FF1F1F1F' +Card = 'FF1F1F1F' +Button = 'FF1F1F1F' +ButtonDark = 'FF171717' +ButtonLht = 'FF555555' +ButtonMed = 'FF2D2D2D' +Indicator = 'FF999999' +Text = 'FFFFFFFF' +Subtitle = 'FF999999' + +# These are dependent on the regions background color +# TextLht = GetAlpha(FFFFFFFF, 90), +# TextMed = GetAlpha(FFFFFFFF, 75), +# TextDim = GetAlpha(FFFFFFFF, 50), +# TextDis = GetAlpha(FFFFFFFF, 30), +# TextDimDis = GetAlpha(FFFFFFFF, 10), + +Transparent = '00000000' +Black = 'FF000000' +Blue = 'FF0033CC' +Red = 'FFC23529' +RedAlt = 'FFD9534F' +Green = 'FF5CB85C' +Orange = 'FFCC7B19' +OrangeLight = 'FFF9BE03' + +# Component specific +# SettingsBg = 'FF2C2C2C' +# ScrollbarBg = GetAlpha(FFFFFFFF, 10), +# ScrollbarFgFocus = GetAlpha('Orange' 70), +# ScrollbarFg = GetAlpha(FFFFFFFF, 25), +# IndicatorBorder = 'FF000000' +# Separator = 'FF000000' +# Modal = GetAlpha(FF000000, 85), +# Focus = Orange, +# FocusToggle = OrangeLight, + +# ListFocusBg = GetAlpha(FF000000, 40), +# TrackActionsBg = GetAlpha(FF000000, 30), +# ListBg = GetAlpha(FFFFFFFF, 10), +# ListBgNoBlur = GetAlpha(FF666666, 40), +# ListImageBorder = 'FF595959' + +SearchBg = 'FF2D2D2D' +SearchButton = 'FF282828' +SearchButtonDark = 'FF1F1F1F' +SearchButtonLight = 'FF555555' + +InputBg = 'FF000000' +InputButton = 'FF282828' +InputButtonDark = 'FF1F1F1F' +InputButtonLight = 'FF555555' + + +class _noAlpha: + def __getattr__(self, name): + return globals()[name][2:] + +noAlpha = _noAlpha() + + +# def getAlpha(color, percent): +# if isinstance(color, basestring): +# color = COLORS[color] + +# if isinstance(color, int): +# util.ERROR_LOG(str(color) + " is not found in object") + +# return color and int((percent / 100 * 255) - 256) diff --git a/script.plexmod/lib/compat.py b/script.plexmod/lib/compat.py new file mode 100644 index 000000000..d7558a7cb --- /dev/null +++ b/script.plexmod/lib/compat.py @@ -0,0 +1,21 @@ +from __future__ import absolute_import +import time +import datetime + +try: + datetime.datetime.strptime('0', '%H') +except TypeError: + # Fix for datetime issues with XBMC/Kodi + class new_datetime(datetime.datetime): + @classmethod + def strptime(cls, dstring, dformat): + return datetime.datetime(*(time.strptime(dstring, dformat)[0:6])) + + datetime.datetime = new_datetime + + +def timedelta_total_seconds(td): + try: + return td.total_seconds() + except: + return ((float(td.seconds) + float(td.days) * 24 * 3600) * 10**6) / 10**6 diff --git a/script.plexmod/lib/distro.py b/script.plexmod/lib/distro.py new file mode 100644 index 000000000..89e186804 --- /dev/null +++ b/script.plexmod/lib/distro.py @@ -0,0 +1,1399 @@ +#!/usr/bin/env python +# Copyright 2015,2016,2017 Nir Cohen +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +The ``distro`` package (``distro`` stands for Linux Distribution) provides +information about the Linux distribution it runs on, such as a reliable +machine-readable distro ID, or version information. + +It is the recommended replacement for Python's original +:py:func:`platform.linux_distribution` function, but it provides much more +functionality. An alternative implementation became necessary because Python +3.5 deprecated this function, and Python 3.8 removed it altogether. Its +predecessor function :py:func:`platform.dist` was already deprecated since +Python 2.6 and removed in Python 3.8. Still, there are many cases in which +access to OS distribution information is needed. See `Python issue 1322 +`_ for more information. +""" + +import argparse +import json +import logging +import os +import re +import shlex +import subprocess +import sys +import warnings +from typing import ( + Any, + Callable, + Dict, + Iterable, + Optional, + Sequence, + TextIO, + Tuple, + Type, +) + +try: + from typing import TypedDict +except ImportError: + # Python 3.7 + TypedDict = dict + +__version__ = "1.8.0" + + +class VersionDict(TypedDict): + major: str + minor: str + build_number: str + + +class InfoDict(TypedDict): + id: str + version: str + version_parts: VersionDict + like: str + codename: str + + +_UNIXCONFDIR = os.environ.get("UNIXCONFDIR", "/etc") +_UNIXUSRLIBDIR = os.environ.get("UNIXUSRLIBDIR", "/usr/lib") +_OS_RELEASE_BASENAME = "os-release" + +#: Translation table for normalizing the "ID" attribute defined in os-release +#: files, for use by the :func:`distro.id` method. +#: +#: * Key: Value as defined in the os-release file, translated to lower case, +#: with blanks translated to underscores. +#: +#: * Value: Normalized value. +NORMALIZED_OS_ID = { + "ol": "oracle", # Oracle Linux + "opensuse-leap": "opensuse", # Newer versions of OpenSuSE report as opensuse-leap +} + +#: Translation table for normalizing the "Distributor ID" attribute returned by +#: the lsb_release command, for use by the :func:`distro.id` method. +#: +#: * Key: Value as returned by the lsb_release command, translated to lower +#: case, with blanks translated to underscores. +#: +#: * Value: Normalized value. +NORMALIZED_LSB_ID = { + "enterpriseenterpriseas": "oracle", # Oracle Enterprise Linux 4 + "enterpriseenterpriseserver": "oracle", # Oracle Linux 5 + "redhatenterpriseworkstation": "rhel", # RHEL 6, 7 Workstation + "redhatenterpriseserver": "rhel", # RHEL 6, 7 Server + "redhatenterprisecomputenode": "rhel", # RHEL 6 ComputeNode +} + +#: Translation table for normalizing the distro ID derived from the file name +#: of distro release files, for use by the :func:`distro.id` method. +#: +#: * Key: Value as derived from the file name of a distro release file, +#: translated to lower case, with blanks translated to underscores. +#: +#: * Value: Normalized value. +NORMALIZED_DISTRO_ID = { + "redhat": "rhel", # RHEL 6.x, 7.x +} + +# Pattern for content of distro release file (reversed) +_DISTRO_RELEASE_CONTENT_REVERSED_PATTERN = re.compile( + r"(?:[^)]*\)(.*)\()? *(?:STL )?([\d.+\-a-z]*\d) *(?:esaeler *)?(.+)" +) + +# Pattern for base file name of distro release file +_DISTRO_RELEASE_BASENAME_PATTERN = re.compile(r"(\w+)[-_](release|version)$") + +# Base file names to be looked up for if _UNIXCONFDIR is not readable. +_DISTRO_RELEASE_BASENAMES = [ + "SuSE-release", + "arch-release", + "base-release", + "centos-release", + "fedora-release", + "gentoo-release", + "mageia-release", + "mandrake-release", + "mandriva-release", + "mandrivalinux-release", + "manjaro-release", + "oracle-release", + "redhat-release", + "rocky-release", + "sl-release", + "slackware-version", +] + +# Base file names to be ignored when searching for distro release file +_DISTRO_RELEASE_IGNORE_BASENAMES = ( + "debian_version", + "lsb-release", + "oem-release", + _OS_RELEASE_BASENAME, + "system-release", + "plesk-release", + "iredmail-release", +) + + +def linux_distribution(full_distribution_name: bool = True) -> Tuple[str, str, str]: + """ + .. deprecated:: 1.6.0 + + :func:`distro.linux_distribution()` is deprecated. It should only be + used as a compatibility shim with Python's + :py:func:`platform.linux_distribution()`. Please use :func:`distro.id`, + :func:`distro.version` and :func:`distro.name` instead. + + Return information about the current OS distribution as a tuple + ``(id_name, version, codename)`` with items as follows: + + * ``id_name``: If *full_distribution_name* is false, the result of + :func:`distro.id`. Otherwise, the result of :func:`distro.name`. + + * ``version``: The result of :func:`distro.version`. + + * ``codename``: The extra item (usually in parentheses) after the + os-release version number, or the result of :func:`distro.codename`. + + The interface of this function is compatible with the original + :py:func:`platform.linux_distribution` function, supporting a subset of + its parameters. + + The data it returns may not exactly be the same, because it uses more data + sources than the original function, and that may lead to different data if + the OS distribution is not consistent across multiple data sources it + provides (there are indeed such distributions ...). + + Another reason for differences is the fact that the :func:`distro.id` + method normalizes the distro ID string to a reliable machine-readable value + for a number of popular OS distributions. + """ + warnings.warn( + "distro.linux_distribution() is deprecated. It should only be used as a " + "compatibility shim with Python's platform.linux_distribution(). Please use " + "distro.id(), distro.version() and distro.name() instead.", + DeprecationWarning, + stacklevel=2, + ) + return _distro.linux_distribution(full_distribution_name) + + +def id() -> str: + """ + Return the distro ID of the current distribution, as a + machine-readable string. + + For a number of OS distributions, the returned distro ID value is + *reliable*, in the sense that it is documented and that it does not change + across releases of the distribution. + + This package maintains the following reliable distro ID values: + + ============== ========================================= + Distro ID Distribution + ============== ========================================= + "ubuntu" Ubuntu + "debian" Debian + "rhel" RedHat Enterprise Linux + "centos" CentOS + "fedora" Fedora + "sles" SUSE Linux Enterprise Server + "opensuse" openSUSE + "amzn" Amazon Linux + "arch" Arch Linux + "buildroot" Buildroot + "cloudlinux" CloudLinux OS + "exherbo" Exherbo Linux + "gentoo" GenToo Linux + "ibm_powerkvm" IBM PowerKVM + "kvmibm" KVM for IBM z Systems + "linuxmint" Linux Mint + "mageia" Mageia + "mandriva" Mandriva Linux + "parallels" Parallels + "pidora" Pidora + "raspbian" Raspbian + "oracle" Oracle Linux (and Oracle Enterprise Linux) + "scientific" Scientific Linux + "slackware" Slackware + "xenserver" XenServer + "openbsd" OpenBSD + "netbsd" NetBSD + "freebsd" FreeBSD + "midnightbsd" MidnightBSD + "rocky" Rocky Linux + "aix" AIX + "guix" Guix System + ============== ========================================= + + If you have a need to get distros for reliable IDs added into this set, + or if you find that the :func:`distro.id` function returns a different + distro ID for one of the listed distros, please create an issue in the + `distro issue tracker`_. + + **Lookup hierarchy and transformations:** + + First, the ID is obtained from the following sources, in the specified + order. The first available and non-empty value is used: + + * the value of the "ID" attribute of the os-release file, + + * the value of the "Distributor ID" attribute returned by the lsb_release + command, + + * the first part of the file name of the distro release file, + + The so determined ID value then passes the following transformations, + before it is returned by this method: + + * it is translated to lower case, + + * blanks (which should not be there anyway) are translated to underscores, + + * a normalization of the ID is performed, based upon + `normalization tables`_. The purpose of this normalization is to ensure + that the ID is as reliable as possible, even across incompatible changes + in the OS distributions. A common reason for an incompatible change is + the addition of an os-release file, or the addition of the lsb_release + command, with ID values that differ from what was previously determined + from the distro release file name. + """ + return _distro.id() + + +def name(pretty: bool = False) -> str: + """ + Return the name of the current OS distribution, as a human-readable + string. + + If *pretty* is false, the name is returned without version or codename. + (e.g. "CentOS Linux") + + If *pretty* is true, the version and codename are appended. + (e.g. "CentOS Linux 7.1.1503 (Core)") + + **Lookup hierarchy:** + + The name is obtained from the following sources, in the specified order. + The first available and non-empty value is used: + + * If *pretty* is false: + + - the value of the "NAME" attribute of the os-release file, + + - the value of the "Distributor ID" attribute returned by the lsb_release + command, + + - the value of the "" field of the distro release file. + + * If *pretty* is true: + + - the value of the "PRETTY_NAME" attribute of the os-release file, + + - the value of the "Description" attribute returned by the lsb_release + command, + + - the value of the "" field of the distro release file, appended + with the value of the pretty version ("" and "" + fields) of the distro release file, if available. + """ + return _distro.name(pretty) + + +def version(pretty: bool = False, best: bool = False) -> str: + """ + Return the version of the current OS distribution, as a human-readable + string. + + If *pretty* is false, the version is returned without codename (e.g. + "7.0"). + + If *pretty* is true, the codename in parenthesis is appended, if the + codename is non-empty (e.g. "7.0 (Maipo)"). + + Some distributions provide version numbers with different precisions in + the different sources of distribution information. Examining the different + sources in a fixed priority order does not always yield the most precise + version (e.g. for Debian 8.2, or CentOS 7.1). + + Some other distributions may not provide this kind of information. In these + cases, an empty string would be returned. This behavior can be observed + with rolling releases distributions (e.g. Arch Linux). + + The *best* parameter can be used to control the approach for the returned + version: + + If *best* is false, the first non-empty version number in priority order of + the examined sources is returned. + + If *best* is true, the most precise version number out of all examined + sources is returned. + + **Lookup hierarchy:** + + In all cases, the version number is obtained from the following sources. + If *best* is false, this order represents the priority order: + + * the value of the "VERSION_ID" attribute of the os-release file, + * the value of the "Release" attribute returned by the lsb_release + command, + * the version number parsed from the "" field of the first line + of the distro release file, + * the version number parsed from the "PRETTY_NAME" attribute of the + os-release file, if it follows the format of the distro release files. + * the version number parsed from the "Description" attribute returned by + the lsb_release command, if it follows the format of the distro release + files. + """ + return _distro.version(pretty, best) + + +def version_parts(best: bool = False) -> Tuple[str, str, str]: + """ + Return the version of the current OS distribution as a tuple + ``(major, minor, build_number)`` with items as follows: + + * ``major``: The result of :func:`distro.major_version`. + + * ``minor``: The result of :func:`distro.minor_version`. + + * ``build_number``: The result of :func:`distro.build_number`. + + For a description of the *best* parameter, see the :func:`distro.version` + method. + """ + return _distro.version_parts(best) + + +def major_version(best: bool = False) -> str: + """ + Return the major version of the current OS distribution, as a string, + if provided. + Otherwise, the empty string is returned. The major version is the first + part of the dot-separated version string. + + For a description of the *best* parameter, see the :func:`distro.version` + method. + """ + return _distro.major_version(best) + + +def minor_version(best: bool = False) -> str: + """ + Return the minor version of the current OS distribution, as a string, + if provided. + Otherwise, the empty string is returned. The minor version is the second + part of the dot-separated version string. + + For a description of the *best* parameter, see the :func:`distro.version` + method. + """ + return _distro.minor_version(best) + + +def build_number(best: bool = False) -> str: + """ + Return the build number of the current OS distribution, as a string, + if provided. + Otherwise, the empty string is returned. The build number is the third part + of the dot-separated version string. + + For a description of the *best* parameter, see the :func:`distro.version` + method. + """ + return _distro.build_number(best) + + +def like() -> str: + """ + Return a space-separated list of distro IDs of distributions that are + closely related to the current OS distribution in regards to packaging + and programming interfaces, for example distributions the current + distribution is a derivative from. + + **Lookup hierarchy:** + + This information item is only provided by the os-release file. + For details, see the description of the "ID_LIKE" attribute in the + `os-release man page + `_. + """ + return _distro.like() + + +def codename() -> str: + """ + Return the codename for the release of the current OS distribution, + as a string. + + If the distribution does not have a codename, an empty string is returned. + + Note that the returned codename is not always really a codename. For + example, openSUSE returns "x86_64". This function does not handle such + cases in any special way and just returns the string it finds, if any. + + **Lookup hierarchy:** + + * the codename within the "VERSION" attribute of the os-release file, if + provided, + + * the value of the "Codename" attribute returned by the lsb_release + command, + + * the value of the "" field of the distro release file. + """ + return _distro.codename() + + +def info(pretty: bool = False, best: bool = False) -> InfoDict: + """ + Return certain machine-readable information items about the current OS + distribution in a dictionary, as shown in the following example: + + .. sourcecode:: python + + { + 'id': 'rhel', + 'version': '7.0', + 'version_parts': { + 'major': '7', + 'minor': '0', + 'build_number': '' + }, + 'like': 'fedora', + 'codename': 'Maipo' + } + + The dictionary structure and keys are always the same, regardless of which + information items are available in the underlying data sources. The values + for the various keys are as follows: + + * ``id``: The result of :func:`distro.id`. + + * ``version``: The result of :func:`distro.version`. + + * ``version_parts -> major``: The result of :func:`distro.major_version`. + + * ``version_parts -> minor``: The result of :func:`distro.minor_version`. + + * ``version_parts -> build_number``: The result of + :func:`distro.build_number`. + + * ``like``: The result of :func:`distro.like`. + + * ``codename``: The result of :func:`distro.codename`. + + For a description of the *pretty* and *best* parameters, see the + :func:`distro.version` method. + """ + return _distro.info(pretty, best) + + +def os_release_info() -> Dict[str, str]: + """ + Return a dictionary containing key-value pairs for the information items + from the os-release file data source of the current OS distribution. + + See `os-release file`_ for details about these information items. + """ + return _distro.os_release_info() + + +def lsb_release_info() -> Dict[str, str]: + """ + Return a dictionary containing key-value pairs for the information items + from the lsb_release command data source of the current OS distribution. + + See `lsb_release command output`_ for details about these information + items. + """ + return _distro.lsb_release_info() + + +def distro_release_info() -> Dict[str, str]: + """ + Return a dictionary containing key-value pairs for the information items + from the distro release file data source of the current OS distribution. + + See `distro release file`_ for details about these information items. + """ + return _distro.distro_release_info() + + +def uname_info() -> Dict[str, str]: + """ + Return a dictionary containing key-value pairs for the information items + from the distro release file data source of the current OS distribution. + """ + return _distro.uname_info() + + +def os_release_attr(attribute: str) -> str: + """ + Return a single named information item from the os-release file data source + of the current OS distribution. + + Parameters: + + * ``attribute`` (string): Key of the information item. + + Returns: + + * (string): Value of the information item, if the item exists. + The empty string, if the item does not exist. + + See `os-release file`_ for details about these information items. + """ + return _distro.os_release_attr(attribute) + + +def lsb_release_attr(attribute: str) -> str: + """ + Return a single named information item from the lsb_release command output + data source of the current OS distribution. + + Parameters: + + * ``attribute`` (string): Key of the information item. + + Returns: + + * (string): Value of the information item, if the item exists. + The empty string, if the item does not exist. + + See `lsb_release command output`_ for details about these information + items. + """ + return _distro.lsb_release_attr(attribute) + + +def distro_release_attr(attribute: str) -> str: + """ + Return a single named information item from the distro release file + data source of the current OS distribution. + + Parameters: + + * ``attribute`` (string): Key of the information item. + + Returns: + + * (string): Value of the information item, if the item exists. + The empty string, if the item does not exist. + + See `distro release file`_ for details about these information items. + """ + return _distro.distro_release_attr(attribute) + + +def uname_attr(attribute: str) -> str: + """ + Return a single named information item from the distro release file + data source of the current OS distribution. + + Parameters: + + * ``attribute`` (string): Key of the information item. + + Returns: + + * (string): Value of the information item, if the item exists. + The empty string, if the item does not exist. + """ + return _distro.uname_attr(attribute) + + +try: + from functools import cached_property +except ImportError: + # Python < 3.8 + class cached_property: # type: ignore + """A version of @property which caches the value. On access, it calls the + underlying function and sets the value in `__dict__` so future accesses + will not re-call the property. + """ + + def __init__(self, f: Callable[[Any], Any]) -> None: + self._fname = f.__name__ + self._f = f + + def __get__(self, obj: Any, owner: Type[Any]) -> Any: + assert obj is not None, f"call {self._fname} on an instance" + ret = obj.__dict__[self._fname] = self._f(obj) + return ret + + +class LinuxDistribution: + """ + Provides information about a OS distribution. + + This package creates a private module-global instance of this class with + default initialization arguments, that is used by the + `consolidated accessor functions`_ and `single source accessor functions`_. + By using default initialization arguments, that module-global instance + returns data about the current OS distribution (i.e. the distro this + package runs on). + + Normally, it is not necessary to create additional instances of this class. + However, in situations where control is needed over the exact data sources + that are used, instances of this class can be created with a specific + distro release file, or a specific os-release file, or without invoking the + lsb_release command. + """ + + def __init__( + self, + include_lsb: Optional[bool] = None, + os_release_file: str = "", + distro_release_file: str = "", + include_uname: Optional[bool] = None, + root_dir: Optional[str] = None, + include_oslevel: Optional[bool] = None, + ) -> None: + """ + The initialization method of this class gathers information from the + available data sources, and stores that in private instance attributes. + Subsequent access to the information items uses these private instance + attributes, so that the data sources are read only once. + + Parameters: + + * ``include_lsb`` (bool): Controls whether the + `lsb_release command output`_ is included as a data source. + + If the lsb_release command is not available in the program execution + path, the data source for the lsb_release command will be empty. + + * ``os_release_file`` (string): The path name of the + `os-release file`_ that is to be used as a data source. + + An empty string (the default) will cause the default path name to + be used (see `os-release file`_ for details). + + If the specified or defaulted os-release file does not exist, the + data source for the os-release file will be empty. + + * ``distro_release_file`` (string): The path name of the + `distro release file`_ that is to be used as a data source. + + An empty string (the default) will cause a default search algorithm + to be used (see `distro release file`_ for details). + + If the specified distro release file does not exist, or if no default + distro release file can be found, the data source for the distro + release file will be empty. + + * ``include_uname`` (bool): Controls whether uname command output is + included as a data source. If the uname command is not available in + the program execution path the data source for the uname command will + be empty. + + * ``root_dir`` (string): The absolute path to the root directory to use + to find distro-related information files. Note that ``include_*`` + parameters must not be enabled in combination with ``root_dir``. + + * ``include_oslevel`` (bool): Controls whether (AIX) oslevel command + output is included as a data source. If the oslevel command is not + available in the program execution path the data source will be + empty. + + Public instance attributes: + + * ``os_release_file`` (string): The path name of the + `os-release file`_ that is actually used as a data source. The + empty string if no distro release file is used as a data source. + + * ``distro_release_file`` (string): The path name of the + `distro release file`_ that is actually used as a data source. The + empty string if no distro release file is used as a data source. + + * ``include_lsb`` (bool): The result of the ``include_lsb`` parameter. + This controls whether the lsb information will be loaded. + + * ``include_uname`` (bool): The result of the ``include_uname`` + parameter. This controls whether the uname information will + be loaded. + + * ``include_oslevel`` (bool): The result of the ``include_oslevel`` + parameter. This controls whether (AIX) oslevel information will be + loaded. + + * ``root_dir`` (string): The result of the ``root_dir`` parameter. + The absolute path to the root directory to use to find distro-related + information files. + + Raises: + + * :py:exc:`ValueError`: Initialization parameters combination is not + supported. + + * :py:exc:`OSError`: Some I/O issue with an os-release file or distro + release file. + + * :py:exc:`UnicodeError`: A data source has unexpected characters or + uses an unexpected encoding. + """ + self.root_dir = root_dir + self.etc_dir = os.path.join(root_dir, "etc") if root_dir else _UNIXCONFDIR + self.usr_lib_dir = ( + os.path.join(root_dir, "usr/lib") if root_dir else _UNIXUSRLIBDIR + ) + + if os_release_file: + self.os_release_file = os_release_file + else: + etc_dir_os_release_file = os.path.join(self.etc_dir, _OS_RELEASE_BASENAME) + usr_lib_os_release_file = os.path.join( + self.usr_lib_dir, _OS_RELEASE_BASENAME + ) + + # NOTE: The idea is to respect order **and** have it set + # at all times for API backwards compatibility. + if os.path.isfile(etc_dir_os_release_file) or not os.path.isfile( + usr_lib_os_release_file + ): + self.os_release_file = etc_dir_os_release_file + else: + self.os_release_file = usr_lib_os_release_file + + self.distro_release_file = distro_release_file or "" # updated later + + is_root_dir_defined = root_dir is not None + if is_root_dir_defined and (include_lsb or include_uname or include_oslevel): + raise ValueError( + "Including subprocess data sources from specific root_dir is disallowed" + " to prevent false information" + ) + self.include_lsb = ( + include_lsb if include_lsb is not None else not is_root_dir_defined + ) + self.include_uname = ( + include_uname if include_uname is not None else not is_root_dir_defined + ) + self.include_oslevel = ( + include_oslevel if include_oslevel is not None else not is_root_dir_defined + ) + + def __repr__(self) -> str: + """Return repr of all info""" + return ( + "LinuxDistribution(" + "os_release_file={self.os_release_file!r}, " + "distro_release_file={self.distro_release_file!r}, " + "include_lsb={self.include_lsb!r}, " + "include_uname={self.include_uname!r}, " + "include_oslevel={self.include_oslevel!r}, " + "root_dir={self.root_dir!r}, " + "_os_release_info={self._os_release_info!r}, " + "_lsb_release_info={self._lsb_release_info!r}, " + "_distro_release_info={self._distro_release_info!r}, " + "_uname_info={self._uname_info!r}, " + "_oslevel_info={self._oslevel_info!r})".format(self=self) + ) + + def linux_distribution( + self, full_distribution_name: bool = True + ) -> Tuple[str, str, str]: + """ + Return information about the OS distribution that is compatible + with Python's :func:`platform.linux_distribution`, supporting a subset + of its parameters. + + For details, see :func:`distro.linux_distribution`. + """ + return ( + self.name() if full_distribution_name else self.id(), + self.version(), + self._os_release_info.get("release_codename") or self.codename(), + ) + + def id(self) -> str: + """Return the distro ID of the OS distribution, as a string. + + For details, see :func:`distro.id`. + """ + + def normalize(distro_id: str, table: Dict[str, str]) -> str: + distro_id = distro_id.lower().replace(" ", "_") + return table.get(distro_id, distro_id) + + distro_id = self.os_release_attr("id") + if distro_id: + return normalize(distro_id, NORMALIZED_OS_ID) + + distro_id = self.lsb_release_attr("distributor_id") + if distro_id: + return normalize(distro_id, NORMALIZED_LSB_ID) + + distro_id = self.distro_release_attr("id") + if distro_id: + return normalize(distro_id, NORMALIZED_DISTRO_ID) + + distro_id = self.uname_attr("id") + if distro_id: + return normalize(distro_id, NORMALIZED_DISTRO_ID) + + return "" + + def name(self, pretty: bool = False) -> str: + """ + Return the name of the OS distribution, as a string. + + For details, see :func:`distro.name`. + """ + name = ( + self.os_release_attr("name") + or self.lsb_release_attr("distributor_id") + or self.distro_release_attr("name") + or self.uname_attr("name") + ) + if pretty: + name = self.os_release_attr("pretty_name") or self.lsb_release_attr( + "description" + ) + if not name: + name = self.distro_release_attr("name") or self.uname_attr("name") + version = self.version(pretty=True) + if version: + name = f"{name} {version}" + return name or "" + + def version(self, pretty: bool = False, best: bool = False) -> str: + """ + Return the version of the OS distribution, as a string. + + For details, see :func:`distro.version`. + """ + versions = [ + self.os_release_attr("version_id"), + self.lsb_release_attr("release"), + self.distro_release_attr("version_id"), + self._parse_distro_release_content(self.os_release_attr("pretty_name")).get( + "version_id", "" + ), + self._parse_distro_release_content( + self.lsb_release_attr("description") + ).get("version_id", ""), + self.uname_attr("release"), + ] + if self.uname_attr("id").startswith("aix"): + # On AIX platforms, prefer oslevel command output. + versions.insert(0, self.oslevel_info()) + elif self.id() == "debian" or "debian" in self.like().split(): + # On Debian-like, add debian_version file content to candidates list. + versions.append(self._debian_version) + version = "" + if best: + # This algorithm uses the last version in priority order that has + # the best precision. If the versions are not in conflict, that + # does not matter; otherwise, using the last one instead of the + # first one might be considered a surprise. + for v in versions: + if v.count(".") > version.count(".") or version == "": + version = v + else: + for v in versions: + if v != "": + version = v + break + if pretty and version and self.codename(): + version = f"{version} ({self.codename()})" + return version + + def version_parts(self, best: bool = False) -> Tuple[str, str, str]: + """ + Return the version of the OS distribution, as a tuple of version + numbers. + + For details, see :func:`distro.version_parts`. + """ + version_str = self.version(best=best) + if version_str: + version_regex = re.compile(r"(\d+)\.?(\d+)?\.?(\d+)?") + matches = version_regex.match(version_str) + if matches: + major, minor, build_number = matches.groups() + return major, minor or "", build_number or "" + return "", "", "" + + def major_version(self, best: bool = False) -> str: + """ + Return the major version number of the current distribution. + + For details, see :func:`distro.major_version`. + """ + return self.version_parts(best)[0] + + def minor_version(self, best: bool = False) -> str: + """ + Return the minor version number of the current distribution. + + For details, see :func:`distro.minor_version`. + """ + return self.version_parts(best)[1] + + def build_number(self, best: bool = False) -> str: + """ + Return the build number of the current distribution. + + For details, see :func:`distro.build_number`. + """ + return self.version_parts(best)[2] + + def like(self) -> str: + """ + Return the IDs of distributions that are like the OS distribution. + + For details, see :func:`distro.like`. + """ + return self.os_release_attr("id_like") or "" + + def codename(self) -> str: + """ + Return the codename of the OS distribution. + + For details, see :func:`distro.codename`. + """ + try: + # Handle os_release specially since distros might purposefully set + # this to empty string to have no codename + return self._os_release_info["codename"] + except KeyError: + return ( + self.lsb_release_attr("codename") + or self.distro_release_attr("codename") + or "" + ) + + def info(self, pretty: bool = False, best: bool = False) -> InfoDict: + """ + Return certain machine-readable information about the OS + distribution. + + For details, see :func:`distro.info`. + """ + return dict( + id=self.id(), + version=self.version(pretty, best), + version_parts=dict( + major=self.major_version(best), + minor=self.minor_version(best), + build_number=self.build_number(best), + ), + like=self.like(), + codename=self.codename(), + ) + + def os_release_info(self) -> Dict[str, str]: + """ + Return a dictionary containing key-value pairs for the information + items from the os-release file data source of the OS distribution. + + For details, see :func:`distro.os_release_info`. + """ + return self._os_release_info + + def lsb_release_info(self) -> Dict[str, str]: + """ + Return a dictionary containing key-value pairs for the information + items from the lsb_release command data source of the OS + distribution. + + For details, see :func:`distro.lsb_release_info`. + """ + return self._lsb_release_info + + def distro_release_info(self) -> Dict[str, str]: + """ + Return a dictionary containing key-value pairs for the information + items from the distro release file data source of the OS + distribution. + + For details, see :func:`distro.distro_release_info`. + """ + return self._distro_release_info + + def uname_info(self) -> Dict[str, str]: + """ + Return a dictionary containing key-value pairs for the information + items from the uname command data source of the OS distribution. + + For details, see :func:`distro.uname_info`. + """ + return self._uname_info + + def oslevel_info(self) -> str: + """ + Return AIX' oslevel command output. + """ + return self._oslevel_info + + def os_release_attr(self, attribute: str) -> str: + """ + Return a single named information item from the os-release file data + source of the OS distribution. + + For details, see :func:`distro.os_release_attr`. + """ + return self._os_release_info.get(attribute, "") + + def lsb_release_attr(self, attribute: str) -> str: + """ + Return a single named information item from the lsb_release command + output data source of the OS distribution. + + For details, see :func:`distro.lsb_release_attr`. + """ + return self._lsb_release_info.get(attribute, "") + + def distro_release_attr(self, attribute: str) -> str: + """ + Return a single named information item from the distro release file + data source of the OS distribution. + + For details, see :func:`distro.distro_release_attr`. + """ + return self._distro_release_info.get(attribute, "") + + def uname_attr(self, attribute: str) -> str: + """ + Return a single named information item from the uname command + output data source of the OS distribution. + + For details, see :func:`distro.uname_attr`. + """ + return self._uname_info.get(attribute, "") + + @cached_property + def _os_release_info(self) -> Dict[str, str]: + """ + Get the information items from the specified os-release file. + + Returns: + A dictionary containing all information items. + """ + if os.path.isfile(self.os_release_file): + with open(self.os_release_file, encoding="utf-8") as release_file: + return self._parse_os_release_content(release_file) + return {} + + @staticmethod + def _parse_os_release_content(lines: TextIO) -> Dict[str, str]: + """ + Parse the lines of an os-release file. + + Parameters: + + * lines: Iterable through the lines in the os-release file. + Each line must be a unicode string or a UTF-8 encoded byte + string. + + Returns: + A dictionary containing all information items. + """ + props = {} + lexer = shlex.shlex(lines, posix=True) + lexer.whitespace_split = True + + tokens = list(lexer) + for token in tokens: + # At this point, all shell-like parsing has been done (i.e. + # comments processed, quotes and backslash escape sequences + # processed, multi-line values assembled, trailing newlines + # stripped, etc.), so the tokens are now either: + # * variable assignments: var=value + # * commands or their arguments (not allowed in os-release) + # Ignore any tokens that are not variable assignments + if "=" in token: + k, v = token.split("=", 1) + props[k.lower()] = v + + if "version" in props: + # extract release codename (if any) from version attribute + match = re.search(r"\((\D+)\)|,\s*(\D+)", props["version"]) + if match: + release_codename = match.group(1) or match.group(2) + props["codename"] = props["release_codename"] = release_codename + + if "version_codename" in props: + # os-release added a version_codename field. Use that in + # preference to anything else Note that some distros purposefully + # do not have code names. They should be setting + # version_codename="" + props["codename"] = props["version_codename"] + elif "ubuntu_codename" in props: + # Same as above but a non-standard field name used on older Ubuntus + props["codename"] = props["ubuntu_codename"] + + return props + + @cached_property + def _lsb_release_info(self) -> Dict[str, str]: + """ + Get the information items from the lsb_release command output. + + Returns: + A dictionary containing all information items. + """ + if not self.include_lsb: + return {} + try: + cmd = ("lsb_release", "-a") + stdout = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + # Command not found or lsb_release returned error + except (OSError, subprocess.CalledProcessError): + return {} + content = self._to_str(stdout).splitlines() + return self._parse_lsb_release_content(content) + + @staticmethod + def _parse_lsb_release_content(lines: Iterable[str]) -> Dict[str, str]: + """ + Parse the output of the lsb_release command. + + Parameters: + + * lines: Iterable through the lines of the lsb_release output. + Each line must be a unicode string or a UTF-8 encoded byte + string. + + Returns: + A dictionary containing all information items. + """ + props = {} + for line in lines: + kv = line.strip("\n").split(":", 1) + if len(kv) != 2: + # Ignore lines without colon. + continue + k, v = kv + props.update({k.replace(" ", "_").lower(): v.strip()}) + return props + + @cached_property + def _uname_info(self) -> Dict[str, str]: + if not self.include_uname: + return {} + try: + cmd = ("uname", "-rs") + stdout = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + except OSError: + return {} + content = self._to_str(stdout).splitlines() + return self._parse_uname_content(content) + + @cached_property + def _oslevel_info(self) -> str: + if not self.include_oslevel: + return "" + try: + stdout = subprocess.check_output("oslevel", stderr=subprocess.DEVNULL) + except (OSError, subprocess.CalledProcessError): + return "" + return self._to_str(stdout).strip() + + @cached_property + def _debian_version(self) -> str: + try: + with open( + os.path.join(self.etc_dir, "debian_version"), encoding="ascii" + ) as fp: + return fp.readline().rstrip() + except FileNotFoundError: + return "" + + @staticmethod + def _parse_uname_content(lines: Sequence[str]) -> Dict[str, str]: + if not lines: + return {} + props = {} + match = re.search(r"^([^\s]+)\s+([\d\.]+)", lines[0].strip()) + if match: + name, version = match.groups() + + # This is to prevent the Linux kernel version from + # appearing as the 'best' version on otherwise + # identifiable distributions. + if name == "Linux": + return {} + props["id"] = name.lower() + props["name"] = name + props["release"] = version + return props + + @staticmethod + def _to_str(bytestring: bytes) -> str: + encoding = sys.getfilesystemencoding() + return bytestring.decode(encoding) + + @cached_property + def _distro_release_info(self) -> Dict[str, str]: + """ + Get the information items from the specified distro release file. + + Returns: + A dictionary containing all information items. + """ + if self.distro_release_file: + # If it was specified, we use it and parse what we can, even if + # its file name or content does not match the expected pattern. + distro_info = self._parse_distro_release_file(self.distro_release_file) + basename = os.path.basename(self.distro_release_file) + # The file name pattern for user-specified distro release files + # is somewhat more tolerant (compared to when searching for the + # file), because we want to use what was specified as best as + # possible. + match = _DISTRO_RELEASE_BASENAME_PATTERN.match(basename) + else: + try: + basenames = [ + basename + for basename in os.listdir(self.etc_dir) + if basename not in _DISTRO_RELEASE_IGNORE_BASENAMES + and os.path.isfile(os.path.join(self.etc_dir, basename)) + ] + # We sort for repeatability in cases where there are multiple + # distro specific files; e.g. CentOS, Oracle, Enterprise all + # containing `redhat-release` on top of their own. + basenames.sort() + except OSError: + # This may occur when /etc is not readable but we can't be + # sure about the *-release files. Check common entries of + # /etc for information. If they turn out to not be there the + # error is handled in `_parse_distro_release_file()`. + basenames = _DISTRO_RELEASE_BASENAMES + for basename in basenames: + match = _DISTRO_RELEASE_BASENAME_PATTERN.match(basename) + if match is None: + continue + filepath = os.path.join(self.etc_dir, basename) + distro_info = self._parse_distro_release_file(filepath) + # The name is always present if the pattern matches. + if "name" not in distro_info: + continue + self.distro_release_file = filepath + break + else: # the loop didn't "break": no candidate. + return {} + + if match is not None: + distro_info["id"] = match.group(1) + + # CloudLinux < 7: manually enrich info with proper id. + if "cloudlinux" in distro_info.get("name", "").lower(): + distro_info["id"] = "cloudlinux" + + return distro_info + + def _parse_distro_release_file(self, filepath: str) -> Dict[str, str]: + """ + Parse a distro release file. + + Parameters: + + * filepath: Path name of the distro release file. + + Returns: + A dictionary containing all information items. + """ + try: + with open(filepath, encoding="utf-8") as fp: + # Only parse the first line. For instance, on SLES there + # are multiple lines. We don't want them... + return self._parse_distro_release_content(fp.readline()) + except OSError: + # Ignore not being able to read a specific, seemingly version + # related file. + # See https://github.com/python-distro/distro/issues/162 + return {} + + @staticmethod + def _parse_distro_release_content(line: str) -> Dict[str, str]: + """ + Parse a line from a distro release file. + + Parameters: + * line: Line from the distro release file. Must be a unicode string + or a UTF-8 encoded byte string. + + Returns: + A dictionary containing all information items. + """ + matches = _DISTRO_RELEASE_CONTENT_REVERSED_PATTERN.match(line.strip()[::-1]) + distro_info = {} + if matches: + # regexp ensures non-None + distro_info["name"] = matches.group(3)[::-1] + if matches.group(2): + distro_info["version_id"] = matches.group(2)[::-1] + if matches.group(1): + distro_info["codename"] = matches.group(1)[::-1] + elif line: + distro_info["name"] = line.strip() + return distro_info + + +_distro = LinuxDistribution() + + +def main() -> None: + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) + logger.addHandler(logging.StreamHandler(sys.stdout)) + + parser = argparse.ArgumentParser(description="OS distro info tool") + parser.add_argument( + "--json", "-j", help="Output in machine readable format", action="store_true" + ) + + parser.add_argument( + "--root-dir", + "-r", + type=str, + dest="root_dir", + help="Path to the root filesystem directory (defaults to /)", + ) + + args = parser.parse_args() + + if args.root_dir: + dist = LinuxDistribution( + include_lsb=False, + include_uname=False, + include_oslevel=False, + root_dir=args.root_dir, + ) + else: + dist = _distro + + if args.json: + logger.info(json.dumps(dist.info(), indent=4, sort_keys=True)) + else: + logger.info("Name: %s", dist.name(pretty=True)) + distribution_version = dist.version(pretty=True) + logger.info("Version: %s", distribution_version) + distribution_codename = dist.codename() + logger.info("Codename: %s", distribution_codename) + + +if __name__ == "__main__": + main() diff --git a/script.plexmod/lib/exceptions.py b/script.plexmod/lib/exceptions.py new file mode 100644 index 000000000..847eb90fc --- /dev/null +++ b/script.plexmod/lib/exceptions.py @@ -0,0 +1,4 @@ +# coding=utf-8 + +class NoDataException(Exception): + pass diff --git a/script.plexmod/lib/image.py b/script.plexmod/lib/image.py new file mode 100644 index 000000000..09d778a3f --- /dev/null +++ b/script.plexmod/lib/image.py @@ -0,0 +1,12 @@ +from __future__ import absolute_import +import os +from lib import util + +CACHE_PATH = os.path.join(util.PROFILE, 'avatars') + +if not os.path.exists(CACHE_PATH): + os.makedirs(CACHE_PATH) + + +def getImage(url, ID): + return url, '' diff --git a/script.plexmod/lib/kodijsonrpc.py b/script.plexmod/lib/kodijsonrpc.py new file mode 100644 index 000000000..325a1222e --- /dev/null +++ b/script.plexmod/lib/kodijsonrpc.py @@ -0,0 +1,106 @@ +from __future__ import absolute_import +from kodi_six import xbmc +import json + + +class JSONRPCMethod: + + class Exception(Exception): + pass + + def __init__(self): + self.family = None + + def __getattr__(self, method): + def handler(**kwargs): + command = { + 'jsonrpc': '2.0', + 'id': 1, + 'method': '{0}.{1}'.format(self.family, method) + } + + if kwargs: + command['params'] = kwargs + + # xbmc.log(json.dumps(command)) + ret = json.loads(xbmc.executeJSONRPC(json.dumps(command))) + + if ret: + if 'error' in ret: + raise self.Exception(ret['error']) + else: + return ret['result'] + else: + return None + + return handler + + def __call__(self, family): + self.family = family + return self + + +class KodiJSONRPC: + def __init__(self): + self.methodHandler = JSONRPCMethod() + + def __getattr__(self, family): + return self.methodHandler(family) + + +rpc = KodiJSONRPC() + + +class BuiltInMethod: + + class Exception(Exception): + pass + + def __init__(self): + self.module = None + + def __getattr__(self, method): + def handler(*args, **kwargs): + args = [str(a).replace(',', '\,') for a in args] + for k, v in kwargs.items(): + args.append('{0}={v}'.format(k, str(v).replace(',', '\,'))) + + if args: + command = '{0}.{1}({2})'.format(self.module, method, ','.join(args)) + else: + command = '{0}.{1}'.format(self.module, method) + + xbmc.log(command, xbmc.LOGINFO) + + xbmc.executebuiltin(command) + + return handler + + def __call__(self, *args, **kwargs): + args = [str(a).replace(',', '\,') for a in args] + for k, v in kwargs.items(): + args.append('{0}={v}'.format(k, str(v).replace(',', '\,'))) + + if args: + command = '{0}({1})'.format(self.module, ','.join(args)) + else: + command = '{0}'.format(self.module) + + xbmc.log(command, xbmc.LOGINFO) + + xbmc.executebuiltin(command) + + def initModule(self, module): + self.module = module + return self + + +class KodiBuiltin: + def __init__(self): + self.methodHandler = BuiltInMethod() + + def __getattr__(self, module): + return self.methodHandler.initModule(module) + + +builtin = KodiBuiltin() diff --git a/script.plexmod/lib/main.py b/script.plexmod/lib/main.py new file mode 100644 index 000000000..bd7b5cf4e --- /dev/null +++ b/script.plexmod/lib/main.py @@ -0,0 +1,178 @@ +from __future__ import absolute_import +from kodi_six import xbmc + +if xbmc.getInfoLabel('Window(10000).Property(script.plex.running)') == "1": + xbmc.executebuiltin('NotifyAll({0},{1},{2})'.format('script.plexmod', 'RESTORE', '{}')) + raise SystemExit + +import gc +import atexit +import threading +import six + +from . import plex + +from plexnet import plexapp +from plexnet import threadutils +from .windows import background, userselect, home, windowutils +from . import player +from . import backgroundthread +from . import util + +BACKGROUND = None + + +if six.PY2: + _Timer = threading._Timer +else: + _Timer = threading.Timer + + +def waitForThreads(): + util.DEBUG_LOG('Main: Checking for any remaining threads') + while len(threading.enumerate()) > 1: + for t in threading.enumerate(): + if t != threading.currentThread(): + if t.is_alive(): + util.DEBUG_LOG('Main: Waiting on: {0}...'.format(t.name)) + if isinstance(t, _Timer): + t.cancel() + + try: + t.join() + except: + util.ERROR() + + +@atexit.register +def realExit(): + xbmc.log('Main: script.plex: REALLY FINISHED', xbmc.LOGINFO) + + +def signout(): + util.setSetting('auth.token', '') + util.DEBUG_LOG('Main: Signing out...') + plexapp.ACCOUNT.signOut() + + +def main(): + global BACKGROUND + try: + with util.Cron(0.1): + BACKGROUND = background.BackgroundWindow.create(function=_main) + + tries = 0 + while not BACKGROUND.isOpen and not util.MONITOR.waitForAbort(2) and tries < 60: + if tries == 0: + util.LOG("Couldn't start main loop, other dialog open? Retrying for 120s.") + BACKGROUND.show() + tries += 1 + + if BACKGROUND.isOpen: + util.setGlobalProperty('running', '1') + BACKGROUND.modal() + del BACKGROUND + else: + util.LOG("Couldn't start main loop, exiting.") + finally: + try: + util.setGlobalProperty('running', '') + util.setGlobalProperty('stop_running', '') + except: + pass + + +def _main(): + util.DEBUG_LOG('[ STARTED: {0} -------------------------------------------------------------------- ]'.format(util.ADDON.getAddonInfo('version'))) + util.DEBUG_LOG('USER-AGENT: {0}'.format(plex.defaultUserAgent())) + background.setSplash() + + try: + while not util.MONITOR.abortRequested() and not util.getGlobalProperty('stop_running'): + if plex.init(): + background.setSplash(False) + fromSwitch = False + while not util.MONITOR.abortRequested() and not util.getGlobalProperty('stop_running'): + if ( + not plexapp.ACCOUNT.isOffline and not + plexapp.ACCOUNT.isAuthenticated and + (len(plexapp.ACCOUNT.homeUsers) > 1 or plexapp.ACCOUNT.isProtected) + + ): + result = userselect.start() + if not result: + return + elif result == 'signout': + signout() + break + elif result == 'signin': + break + elif result == 'cancel' and fromSwitch: + util.DEBUG_LOG('Main: User selection canceled, reusing previous user') + plexapp.ACCOUNT.isAuthenticated = True + + if not fromSwitch: + util.DEBUG_LOG('Main: User selected') + + try: + selectedServer = plexapp.SERVERMANAGER.selectedServer + + if not selectedServer: + background.setBusy() + util.DEBUG_LOG('Main: Waiting for selected server...') + try: + for timeout, skip_preferred, skip_owned in ((10, False, False), (10, True, True)): + plex.CallbackEvent(plexapp.util.APP, 'change:selectedServer', timeout=timeout).wait() + + selectedServer = plexapp.SERVERMANAGER.checkSelectedServerSearch(skip_preferred=skip_preferred, skip_owned=skip_owned) + if selectedServer: + break + else: + util.DEBUG_LOG('Main: Finished waiting for selected server...') + finally: + background.setBusy(False) + + util.DEBUG_LOG('Main: STARTING WITH SERVER: {0}'.format(selectedServer)) + + windowutils.HOME = home.HomeWindow.open() + util.CRON.cancelReceiver(windowutils.HOME) + + if not windowutils.HOME.closeOption: + return + + closeOption = windowutils.HOME.closeOption + + windowutils.shutdownHome() + + if closeOption == 'signout': + signout() + break + elif closeOption == 'switch': + plexapp.ACCOUNT.isAuthenticated = False + fromSwitch = True + finally: + windowutils.shutdownHome() + BACKGROUND.activate() + gc.collect(2) + + else: + break + except: + util.ERROR() + finally: + util.DEBUG_LOG('Main: SHUTTING DOWN...') + background.setShutdown() + player.shutdown() + plexapp.util.APP.preShutdown() + util.CRON.stop() + backgroundthread.BGThreader.shutdown() + plexapp.util.APP.shutdown() + waitForThreads() + background.setBusy(False) + background.setSplash(False) + background.killMonitor() + + util.DEBUG_LOG('FINISHED') + util.shutdown() + + gc.collect(2) diff --git a/script.plexmod/lib/metadata.py b/script.plexmod/lib/metadata.py new file mode 100644 index 000000000..ffa33ff7b --- /dev/null +++ b/script.plexmod/lib/metadata.py @@ -0,0 +1,34 @@ +from __future__ import absolute_import +from plexnet import media +from .util import T + + +EXTRA_MAP = { + media.METADATA_RELATED_TRAILER: T(32201, 'Trailer'), + media.METADATA_RELATED_DELETED_SCENE: T(32202, 'Deleted Scene'), + media.METADATA_RELATED_INTERVIEW: T(32203, 'Interview'), + media.METADATA_RELATED_MUSIC_VIDEO: T(32204, 'Music Video'), + media.METADATA_RELATED_BEHIND_THE_SCENES: T(32205, 'Behind the Scenes'), + media.METADATA_RELATED_SCENE_OR_SAMPLE: T(32206, 'Scene/Sample'), + media.METADATA_RELATED_LIVE_MUSIC_VIDEO: T(32207, 'Live Music Video'), + media.METADATA_RELATED_LYRIC_MUSIC_VIDEO: T(32208, 'Lyric Music Video'), + media.METADATA_RELATED_CONCERT: T(32209, 'Concert'), + media.METADATA_RELATED_FEATURETTE: T(32210, 'Featurette'), + media.METADATA_RELATED_SHORT: T(32211, 'Short'), + media.METADATA_RELATED_OTHER: T(32212, 'Other') +} + + +API_TRANSLATION_MAP = { + 'Unknown': T(32441), + 'Embedded': T(32442), + 'Forced': T(32443), + 'Lyrics': T(32444), + 'Mono': T(32445), + 'Stereo': T(32446), + 'None': T(32447) +} + + +def apiTranslate(string): + return API_TRANSLATION_MAP.get(string) or string diff --git a/script.plexmod/lib/playback_utils.py b/script.plexmod/lib/playback_utils.py new file mode 100644 index 000000000..57c0edb25 --- /dev/null +++ b/script.plexmod/lib/playback_utils.py @@ -0,0 +1,201 @@ +# coding=utf-8 +import json +import os +from collections import namedtuple, OrderedDict + +from kodi_six import xbmc, xbmcaddon, xbmcvfs +from plexnet import plexapp + +from lib import util + + + +ADDON = xbmcaddon.Addon() + + +ATTR_MAP = { + "b": "binge_mode", + "i": "auto_skip_intro", + "c": "auto_skip_credits", + "e": "show_intro_skip_early" +} + +# I know dicts are ordered in py3, but we want to be compatible with py2. +TRANS_MAP = OrderedDict(( + ("binge_mode", 33618), + ("auto_skip_intro", 32522), + ("auto_skip_credits", 32526), + ("show_intro_skip_early", 33505) +)) + +ATTR_MAP_REV = dict((v, k) for k, v in ATTR_MAP.items()) + + +PlaybackSettings = namedtuple("PlaybackSettings", list(v for v in ATTR_MAP.values())) + + +class PlaybackManager(object): + """ + Manages the playback settings for individual shows; falls back to the global default if no specifics set + """ + version = 1 + _data = None + _currentServerUUID = None + _currentUserID = None + + transMap = TRANS_MAP + + dataPath = os.path.join(util.translatePath(ADDON.getAddonInfo("profile")), "playback_settings.json") + + # this could be a property, but w/e + glob = None + + def __init__(self): + self.reset() + # bind settings change signals + for v in ATTR_MAP.values(): + plexapp.util.APP.on('change:{}'.format(v), lambda **kwargs: self.setGlob(**kwargs)) + + plexapp.util.APP.on('change:selectedServer', lambda **kwargs: self.setServerUUID(**kwargs)) + plexapp.util.APP.on("change:user", lambda **kwargs: self.setUserID(**kwargs)) + plexapp.util.APP.on('init', lambda **kwargs: self.setUserID(**kwargs)) + + def __call__(self, obj, key=None, value=None, kv_dict=None): + # shouldn't happen + if not self._currentServerUUID or not self._currentUserID: + util.DEBUG_LOG("APP.PlaybackManager, something's wrong: ServerUUID: %s, UserID: %s" % ( + self._currentServerUUID, self._currentUserID)) + return + + csid = self._currentServerUUID + cuid = self._currentUserID + + # set + if (key is not None and value is not None) or kv_dict is not None: + # prepare value dict + if csid not in self._data: + self._data[csid] = {} + + if cuid not in self._data[csid]: + self._data[csid][cuid] = {} + + ukv = {key: value} if not kv_dict else kv_dict + + if obj.ratingKey not in self._data[csid][cuid]: + self._data[csid][cuid][obj.ratingKey] = {} + + for k, v in ukv.items(): + # don't write globals into the storage + if v != getattr(self.glob, k): + self._data[csid][cuid][obj.ratingKey][ATTR_MAP_REV[k]] = v + else: + # new val set to global default, delete specific val + if ATTR_MAP_REV[k] in self._data[csid][cuid][obj.ratingKey]: + del self._data[csid][cuid][obj.ratingKey][ATTR_MAP_REV[k]] + + # empty specific settings? clean up + if not self._data[csid][cuid][obj.ratingKey]: + del self._data[csid][cuid][obj.ratingKey] + + self.save() + return self.glob._replace(**ukv) + + if not obj.ratingKey: + return self.glob + + # get + data = self._data.get(csid, {}).get(cuid, {}).get(obj.ratingKey, None) + if data: + return self.glob._replace(**dict((ATTR_MAP[k], v) for k, v in data.items())) + return self.glob + + def reset(self): + self._data = self.load() + if plexapp.SERVERMANAGER and plexapp.SERVERMANAGER.selectedServer: + self.setServerUUID() + + if plexapp.ACCOUNT: + self.setUserID() + self.setGlob() + + def setGlob(self, skey=None, value=None, **kwargs): + if skey is not None and value is not None: + self.glob = self.glob._replace(**{skey: value}) + else: + self.glob = PlaybackSettings(**dict((k, util.getUserSetting(k, False)) for k in ATTR_MAP.values())) + + def setServerUUID(self, server=None): + if not server and not plexapp.SERVERMANAGER.selectedServer: + return + self._currentServerUUID = (server if server is not None else plexapp.SERVERMANAGER.selectedServer).uuid + + def setUserID(self, account=None, reallyChanged=False): + if not account and not plexapp.ACCOUNT: + return + self._currentUserID = (account if account is not None and reallyChanged else plexapp.ACCOUNT).ID + self.setGlob() + + def load(self): + # new load method, v1 + if os.path.isfile(self.dataPath): + try: + f = xbmcvfs.File(self.dataPath) + obj = json.loads(f.read()) + f.close() + + version = obj["version"] + data = obj["data"] + except: + util.ERROR("Couldn't load playback_settings.json") + return {} + + if version < self.version: + migratedAny = False + for v in range(version + 1, self.version + 1): + migFunc = "migrateV{}".format(v) + if hasattr(self, migFunc): + migResult, data = getattr(self, migFunc)(data) + if migResult: + util.DEBUG_LOG("Migrated playback_settings.json to format v{}".format(v)) + migratedAny = True + if migratedAny: + self.save(data=data) + + return data + + else: + # migrate legacy data + jstring = plexapp.util.INTERFACE.getRegistry("BingeModeSettings") + if not jstring: + # fallback + jstring = plexapp.util.INTERFACE.getRegistry("AutoSkipSettings") + if not jstring: + return {} + + try: + util.DEBUG_LOG("Loading old BingeModeSettings") + obj = json.loads(jstring) + migData = {} + # migrate old BM settings into new format + for serverID, userIDs in obj.items(): + migData[serverID] = {} + for userID, ratingKeys in userIDs.items(): + migData[serverID][userID] = {} + for ratingKey, value in ratingKeys.items(): + migData[serverID][userID][ratingKey] = {"b": value} + + # plexapp.util.INTERFACE.clearRegistry("BingeModeSettings") + self.save(data=migData) + return migData + except: + util.DEBUG_LOG("Couldn't parse old BingeModeSettings") + return {} + + def save(self, data=None): + try: + f = xbmcvfs.File(self.dataPath, "w") + f.write(json.dumps({"version": self.version, "data": data or self._data})) + f.close() + except: + util.ERROR("Couldn't write playback_settings.json") + return diff --git a/script.plexmod/lib/player.py b/script.plexmod/lib/player.py new file mode 100644 index 000000000..293a744bd --- /dev/null +++ b/script.plexmod/lib/player.py @@ -0,0 +1,1468 @@ +from __future__ import absolute_import +import base64 +import threading +import six +import re +import os + +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import backgroundthread +from . import kodijsonrpc +from . import colors +from .windows import seekdialog +from . import util +from plexnet import plexplayer +from plexnet import plexapp +from plexnet import signalsmixin +from plexnet import util as plexnetUtil +from six.moves import range + +FIVE_MINUTES_MILLIS = 300000 + + +class BasePlayerHandler(object): + def __init__(self, player, session_id=None): + self.player = player + self.media = None + self.baseOffset = 0 + self.timelineType = None + self.lastTimelineState = None + self.ignoreTimelines = False + self.queuingNext = False + self.playQueue = None + self.sessionID = session_id + + def onAVChange(self): + pass + + def onAVStarted(self): + pass + + def onPrePlayStarted(self): + pass + + def onPlayBackStarted(self): + pass + + def onPlayBackPaused(self): + pass + + def onPlayBackResumed(self): + pass + + def onPlayBackStopped(self): + pass + + def onPlayBackEnded(self): + pass + + def onPlayBackSeek(self, stime, offset): + pass + + def onPlayBackFailed(self): + pass + + def onVideoWindowOpened(self): + pass + + def onVideoWindowClosed(self): + pass + + def onVideoOSD(self): + pass + + def onSeekOSD(self): + pass + + def onMonitorInit(self): + pass + + def tick(self): + pass + + def close(self): + pass + + def setSubtitles(self, *args, **kwargs): + pass + + def getIntroOffset(self, offset=None, setSkipped=False): + pass + + def setup(self, duration, meta, offset, bif_url, **kwargs): + pass + + @property + def trueTime(self): + return self.baseOffset + self.player.currentTime + + def getCurrentItem(self): + if self.player.playerObject: + return self.player.playerObject.item + return None + + def shouldSendTimeline(self, item): + return item.ratingKey and item.getServer() + + def currentDuration(self): + if self.player.playerObject and self.player.isPlaying(): + try: + return int(self.player.getTotalTime() * 1000) + except RuntimeError: + pass + + return 0 + + def updateNowPlaying(self, force=False, refreshQueue=False, state=None, time=None): + util.DEBUG_LOG("UpdateNowPlaying: force: {0} refreshQueue: {1} state: {2}".format(force, refreshQueue, state)) + if self.ignoreTimelines: + util.DEBUG_LOG("UpdateNowPlaying: ignoring timeline as requested") + return + + item = self.getCurrentItem() + if not item: + return + + if not self.shouldSendTimeline(item): + return + + state = state or self.player.playState + # Avoid duplicates + if state == self.lastTimelineState and not force: + return + + self.lastTimelineState = state + # self.timelineTimer.reset() + + time = time or int(self.trueTime * 1000) + + # self.trigger("progress", [m, item, time]) + + if refreshQueue and self.playQueue: + self.playQueue.refreshOnTimeline = True + + plexapp.util.APP.nowplayingmanager.updatePlaybackState( + self.timelineType, self.player.playerObject, state, time, self.playQueue, duration=self.currentDuration() + ) + + def getVolume(self): + return util.rpc.Application.GetProperties(properties=["volume"])["volume"] + + +class SeekPlayerHandler(BasePlayerHandler): + NO_SEEK = 0 + SEEK_IN_PROGRESS = 2 + SEEK_PLAYLIST = 3 + SEEK_REWIND = 4 + SEEK_POST_PLAY = 5 + + MODE_ABSOLUTE = 0 + MODE_RELATIVE = 1 + + def __init__(self, player, session_id=None): + BasePlayerHandler.__init__(self, player, session_id) + self.dialog = None + self.playlist = None + self.playQueue = None + self.timelineType = 'video' + self.ended = False + self.bifURL = '' + self.title = '' + self.title2 = '' + self.chapters = None + self.stoppedInBingeMode = False + self.inBingeMode = False + self.prePlayWitnessed = False + self.queuingNext = False + self.reset() + + def reset(self): + self.duration = 0 + self.offset = 0 + self.baseOffset = 0 + self.seeking = self.NO_SEEK + self.seekOnStart = 0 + self.mode = self.MODE_RELATIVE + self.ended = False + self.stoppedInBingeMode = False + self.prePlayWitnessed = False + self.queuingNext = False + + def setup(self, duration, meta, offset, bif_url, title='', title2='', seeking=NO_SEEK, chapters=None): + self.ended = False + self.baseOffset = offset / 1000.0 + self.seeking = seeking + self.duration = duration + self.bifURL = bif_url + self.title = title + self.title2 = title2 + self.chapters = chapters or [] + self.playedThreshold = plexapp.util.INTERFACE.getPlayedThresholdValue() + self.ignoreTimelines = False + self.queuingNext = False + self.stoppedInBingeMode = False + self.inBingeMode = False + self.prePlayWitnessed = False + self.getDialog(setup=True) + self.dialog.setup(self.duration, meta, int(self.baseOffset * 1000), self.bifURL, self.title, self.title2, + chapters=self.chapters, keepMarkerDef=seeking == self.SEEK_IN_PROGRESS) + + def getDialog(self, setup=False): + if not self.dialog: + self.dialog = seekdialog.SeekDialog.create(show=False, handler=self) + + return self.dialog + + @property + def isTranscoded(self): + return self.mode == self.MODE_RELATIVE + + @property + def isDirectPlay(self): + return self.mode == self.MODE_ABSOLUTE + + @property + def trueTime(self): + if self.isTranscoded: + return self.baseOffset + self.player.currentTime + else: + if self.seekOnStart: + return self.player.playerObject.startOffset + (self.seekOnStart / 1000) + else: + return self.player.currentTime + self.player.playerObject.startOffset + + def shouldShowPostPlay(self): + if self.playlist and self.playlist.TYPE == 'playlist': + return False + + if self.inBingeMode and not self.stoppedInBingeMode: + return False + + if (not util.advancedSettings.postplayAlways and self.player.video.duration.asInt() <= FIVE_MINUTES_MILLIS)\ + or util.advancedSettings.postplayTimeout <= 0: + return False + + return True + + def showPostPlay(self): + if not self.shouldShowPostPlay(): + return + + self.seeking = self.SEEK_POST_PLAY + self.hideOSD(delete=True) + + self.player.trigger('post.play', video=self.player.video, playlist=self.playlist, handler=self, + stoppedInBingeMode=self.stoppedInBingeMode) + + self.stoppedInBingeMode = False + + return True + + def getIntroOffset(self, offset=None, setSkipped=False): + return self.getDialog().displayMarkers(onlyReturnIntroMD=True, offset=offset, setSkipped=setSkipped) + + def next(self, on_end=False): + if self.playlist and next(self.playlist): + self.seeking = self.SEEK_PLAYLIST + + if on_end: + if self.showPostPlay(): + return True + + if not self.playlist or self.stoppedInBingeMode: + return False + + xbmc.sleep(500) + + self.player.playVideoPlaylist(self.playlist, handler=self, resume=self.player.resume) + + return True + + def prev(self): + if not self.playlist or not self.playlist.prev(): + return False + + self.seeking = self.SEEK_PLAYLIST + xbmc.sleep(500) + self.player.playVideoPlaylist(self.playlist, handler=self, resume=self.player.resume) + + return True + + def playAt(self, pos): + if not self.playlist or not self.playlist.setCurrent(pos): + return False + + self.seeking = self.SEEK_PLAYLIST + self.player.playVideoPlaylist(self.playlist, handler=self, resume=self.player.resume) + + return True + + def onSeekAborted(self): + if self.seeking: + self.seeking = self.NO_SEEK + self.player.control('play') + + def showOSD(self, from_seek=False): + self.updateOffset() + if self.dialog: + self.dialog.update(self.offset, from_seek) + self.dialog.showOSD() + + def hideOSD(self, delete=False): + util.CRON.forceTick() + if self.dialog: + self.dialog.hideOSD(closing=delete) + if delete: + d = self.dialog + self.dialog = None + d.doClose() + del d + util.garbageCollect() + + def seek(self, offset, settings_changed=False, seeking=SEEK_IN_PROGRESS): + if offset is None: + return + + self.offset = offset + + if self.isDirectPlay and not settings_changed: + util.DEBUG_LOG('New absolute player offset: {0}'.format(self.offset)) + + if self.player.playerObject.offsetIsValid(offset / 1000): + if self.seekAbsolute(offset): + return + + self.updateNowPlaying(state=self.player.STATE_PAUSED) # To for update after seek + + self.seeking = self.SEEK_IN_PROGRESS + + if self.player.playState == self.player.STATE_PAUSED: + self.player.pauseAfterPlaybackStarted = True + + util.DEBUG_LOG('New player offset: {0}'.format(self.offset)) + self.player._playVideo(offset, seeking=self.seeking, force_update=settings_changed) + + def fastforward(self): + xbmc.executebuiltin('PlayerControl(forward)') + + def rewind(self): + if self.isDirectPlay: + xbmc.executebuiltin('PlayerControl(rewind)') + else: + self.seek(max(self.trueTime - 30, 0) * 1000, seeking=self.SEEK_REWIND) + + def seekAbsolute(self, seek=None): + self.seekOnStart = seek or (self.seekOnStart if self.seekOnStart else None) + if self.seekOnStart is not None: + seekSeconds = self.seekOnStart / 1000.0 + try: + if seekSeconds >= self.player.getTotalTime(): + util.DEBUG_LOG("SeekAbsolute: Bad offset: {0}".format(seekSeconds)) + return False + except RuntimeError: # Not playing a file + util.DEBUG_LOG("SeekAbsolute: runtime error") + return False + self.updateNowPlaying(state=self.player.STATE_PAUSED) # To for update after seek + + util.DEBUG_LOG("SeekAbsolute: Seeking to {0}".format(self.seekOnStart)) + self.player.seekTime(self.seekOnStart / 1000.0) + return True + + def onAVChange(self): + util.DEBUG_LOG('SeekHandler: onAVChange') + if self.dialog: + self.dialog.onAVChange() + + def onAVStarted(self): + util.DEBUG_LOG('SeekHandler: onAVStarted') + + if self.dialog: + self.dialog.onAVStarted() + + # check if embedded subtitle was set correctly + if self.isDirectPlay and self.player.video and self.player.video.current_subtitle_is_embedded: + try: + playerID = kodijsonrpc.rpc.Player.GetActivePlayers()[0]["playerid"] + currIdx = kodijsonrpc.rpc.Player.GetProperties(playerid=playerID, properties=['currentsubtitle'])[ + 'currentsubtitle']['index'] + if currIdx != self.player.video._current_subtitle_idx: + util.LOG("Embedded Subtitle index was incorrect ({}), setting to: {}". + format(currIdx, self.player.video._current_subtitle_idx)) + self.dialog.setSubtitles() + else: + util.DEBUG_LOG("Embedded subtitle was correctly set in Kodi") + except: + util.ERROR("Exception when trying to check for embedded subtitles") + + def onPrePlayStarted(self): + util.DEBUG_LOG('SeekHandler: onPrePlayStarted, DP: {}'.format(self.isDirectPlay)) + self.prePlayWitnessed = True + if self.isDirectPlay: + self.setSubtitles(do_sleep=False) + + def onPlayBackStarted(self): + util.DEBUG_LOG('SeekHandler: onPlayBackStarted, DP: {}'.format(self.isDirectPlay)) + self.updateNowPlaying(force=True, refreshQueue=True) + + if self.dialog: + self.dialog.onPlayBackStarted() + + #if not self.prePlayWitnessed and self.isDirectPlay: + if self.isDirectPlay: + self.setSubtitles(do_sleep=False) + + def onPlayBackResumed(self): + self.updateNowPlaying() + if self.dialog: + self.dialog.onPlayBackResumed() + + util.CRON.forceTick() + # self.hideOSD() + + def onPlayBackStopped(self): + util.DEBUG_LOG('SeekHandler: onPlayBackStopped - Seeking={0}'.format(self.seeking)) + + if self.dialog: + self.dialog.onPlayBackStopped() + + if self.queuingNext and self.inBingeMode: + if self.next(on_end=False): + return + + if self.seeking not in (self.SEEK_IN_PROGRESS, self.SEEK_REWIND): + self.updateNowPlaying() + + # show post play if possible, if an item has been watched (90% by Plex standards) + if self.seeking != self.SEEK_PLAYLIST and self.duration: + playedFac = self.trueTime * 1000 / float(self.duration) + util.DEBUG_LOG("Player - played-threshold: {}/{}".format(playedFac, self.playedThreshold)) + if playedFac >= self.playedThreshold and self.next(on_end=True): + return + + if self.seeking not in (self.SEEK_IN_PROGRESS, self.SEEK_PLAYLIST): + self.hideOSD(delete=True) + self.sessionEnded() + + def onPlayBackEnded(self): + util.DEBUG_LOG('SeekHandler: onPlayBackEnded - Seeking={0}'.format(self.seeking)) + + if self.dialog: + self.dialog.onPlayBackEnded() + + if self.player.playerObject.hasMoreParts(): + self.updateNowPlaying(state=self.player.STATE_PAUSED) # To for update after seek + self.seeking = self.SEEK_IN_PROGRESS + self.player._playVideo(self.player.playerObject.getNextPartOffset(), seeking=self.seeking) + return + + self.updateNowPlaying() + + if self.queuingNext: + util.DEBUG_LOG('SeekHandler: onPlayBackEnded - event ignored') + return + + if self.inBingeMode: + self.stoppedInBingeMode = False + + if self.playlist and self.playlist.hasNext(): + self.queuingNext = True + if self.next(on_end=True): + return + else: + self.queuingNext = False + + if not self.ended: + if self.seeking != self.SEEK_PLAYLIST: + self.hideOSD() + + if self.seeking not in (self.SEEK_IN_PROGRESS, self.SEEK_PLAYLIST): + self.sessionEnded() + + def onPlayBackPaused(self): + self.updateNowPlaying() + if self.dialog: + self.dialog.onPlayBackPaused() + + def onPlayBackSeek(self, stime, offset): + if self.dialog: + self.dialog.onPlayBackSeek(stime, offset) + + if self.seekOnStart: + seeked = False + if self.dialog: + seeked = self.dialog.tick(stime) + + if seeked: + util.DEBUG_LOG("OnPlayBackSeek: Seeked on start") + self.seekOnStart = 0 + return + + self.updateOffset() + # self.showOSD(from_seek=True) + + def setSubtitles(self, do_sleep=True, honor_forced_subtitles_override=True): + if not self.player.video: + util.LOG("Warning: SetSubtitles: no player.video object available") + return + + subs = self.player.video.selectedSubtitleStream( + forced_subtitles_override=honor_forced_subtitles_override and util.getSetting("forced_subtitles_override", + False)) + if subs: + if do_sleep: + xbmc.sleep(100) + + path = subs.getSubtitleServerPath() + if self.isDirectPlay: + self.player.showSubtitles(False) + if path: + util.DEBUG_LOG('Setting subtitle path: {0} ({1})'.format(path, subs)) + self.player.setSubtitles(path) + self.player.showSubtitles(True) + + else: + # u_til.TEST(subs.__dict__) + # u_til.TEST(self.player.video.mediaChoice.__dict__) + util.DEBUG_LOG('Enabling embedded subtitles at: {0} ({1})'.format(subs.typeIndex, subs)) + self.player.setSubtitleStream(subs.typeIndex) + self.player.showSubtitles(True) + + else: + self.player.showSubtitles(False) + + def setAudioTrack(self): + if self.isDirectPlay: + track = self.player.video.selectedAudioStream() + if track: + # only try finding the current audio stream when the BG music isn't playing and wasn't the last + # thing played, because currentaudiostream doesn't populate for audio-only items; in that case, + # always select the proper audio stream + if not self.player.lastPlayWasBGM: + try: + playerID = kodijsonrpc.rpc.Player.GetActivePlayers()[0]["playerid"] + currIdx = kodijsonrpc.rpc.Player.GetProperties(playerid=playerID, properties=['currentaudiostream'])['currentaudiostream']['index'] + if currIdx == track.typeIndex: + util.DEBUG_LOG('Audio track is correct index: {0}'.format(track.typeIndex)) + return + except: + util.ERROR() + + self.player.lastPlayWasBGM = False + + xbmc.sleep(100) + util.DEBUG_LOG('Switching audio track - index: {0}'.format(track.typeIndex)) + self.player.setAudioStream(track.typeIndex) + + def updateOffset(self): + try: + self.offset = int(self.player.getTime() * 1000) + except RuntimeError: + pass + + def initPlayback(self): + self.seeking = self.NO_SEEK + + #self.setSubtitles() + if self.isTranscoded and self.player.getAvailableSubtitleStreams(): + util.DEBUG_LOG('Enabling first subtitle stream, as we\'re in DirectStream') + self.player.showSubtitles(True) + self.setAudioTrack() + + if self.isDirectPlay: + self.seekAbsolute() + + def onPlayBackFailed(self): + if self.ended: + return False + + if self.dialog: + self.dialog.onPlayBackFailed() + + util.DEBUG_LOG('SeekHandler: onPlayBackFailed - Seeking={0}'.format(self.seeking)) + if self.seeking not in (self.SEEK_IN_PROGRESS, self.SEEK_PLAYLIST): + self.sessionEnded() + + if self.seeking == self.SEEK_IN_PROGRESS: + return False + else: + self.seeking = self.NO_SEEK + + return True + + # def onSeekOSD(self): + # self.dialog.activate() + + def onVideoWindowOpened(self): + util.DEBUG_LOG('SeekHandler: onVideoWindowOpened - Seeking={0}'.format(self.seeking)) + self.getDialog().show() + + self.initPlayback() + + def onVideoWindowClosed(self): + self.hideOSD() + util.DEBUG_LOG('SeekHandler: onVideoWindowClosed - Seeking={0}'.format(self.seeking)) + if not self.seeking: + if self.player.isPlaying(): + self.player.stop() + if not self.playlist or not self.playlist.hasNext(): + if not self.shouldShowPostPlay(): + self.sessionEnded() + + def onVideoOSD(self): + # xbmc.executebuiltin('Dialog.Close(seekbar,true)') # Doesn't work :) + self.showOSD() + + def tick(self): + if self.seeking != self.SEEK_IN_PROGRESS: + self.updateNowPlaying(force=True) + + if self.dialog: + self.dialog.tick() + + def close(self): + self.hideOSD(delete=True) + + def sessionEnded(self): + if self.ended: + return + self.ended = True + util.DEBUG_LOG('Player: Video session ended') + self.player.trigger('session.ended', session_id=self.sessionID) + self.hideOSD(delete=True) + + __next__ = next + + +class AudioPlayerHandler(BasePlayerHandler): + def __init__(self, player): + BasePlayerHandler.__init__(self, player) + self.timelineType = 'music' + util.setGlobalProperty('track.ID', '') + self.extractTrackInfo() + + def extractTrackInfo(self): + if not self.player.isPlayingAudio(): + return + + plexID = None + for x in range(10): # Wait a sec (if necessary) for this to become available + try: + item = kodijsonrpc.rpc.Player.GetItem(playerid=0, properties=['comment'])['item'] + plexID = item['comment'] + except: + util.ERROR() + + if plexID: + break + xbmc.sleep(100) + + if not plexID: + return + + if not plexID.startswith('PLEX-'): + return + + util.DEBUG_LOG('Extracting track info from comment') + try: + data = plexID.split(':', 1)[-1] + from plexnet import plexobjects + track = plexobjects.PlexObject.deSerialize(base64.urlsafe_b64decode(data.encode('utf-8'))) + track.softReload() + self.media = track + pobj = plexplayer.PlexAudioPlayer(track) + self.player.playerObject = pobj + self.updatePlayQueueTrack(track) + util.setGlobalProperty('track.ID', track.ratingKey) # This is used in the skins to match a listitem + except: + util.ERROR() + + def setPlayQueue(self, pq): + self.playQueue = pq + pq.on('items.changed', self.playQueueCallback) + + def playQueueCallback(self, **kwargs): + plist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) + # plist.clear() + try: + citem = kodijsonrpc.rpc.Player.GetItem(playerid=0, properties=['comment'])['item'] + plexID = citem['comment'].split(':', 1)[0] + except: + util.ERROR() + return + + current = plist.getposition() + size = plist.size() + + # Remove everything but the current track + for x in range(size - 1, current, -1): # First everything with a greater position + kodijsonrpc.rpc.Playlist.Remove(playlistid=xbmc.PLAYLIST_MUSIC, position=x) + for x in range(current): # Then anything with a lesser position + kodijsonrpc.rpc.Playlist.Remove(playlistid=xbmc.PLAYLIST_MUSIC, position=0) + + swap = None + for idx, track in enumerate(self.playQueue.items()): + tid = 'PLEX-{0}'.format(track.ratingKey) + if tid == plexID: + # Save the position of the current track in the pq + swap = idx + + url, li = self.player.createTrackListItem(track, index=idx + 1) + + plist.add(url, li) + + plist[0].setInfo('music', { + 'playcount': swap + 1, + }) + + # Now swap the track to the correct position. This seems to be the only way to update the kodi playlist position to the current track's new position + if swap is not None: + kodijsonrpc.rpc.Playlist.Swap(playlistid=xbmc.PLAYLIST_MUSIC, position1=0, position2=swap + 1) + kodijsonrpc.rpc.Playlist.Remove(playlistid=xbmc.PLAYLIST_MUSIC, position=0) + + self.player.trigger('playlist.changed') + + def updatePlayQueue(self, delay=False): + if not self.playQueue: + return + + self.playQueue.refresh(delay=delay) + + def updatePlayQueueTrack(self, track): + if not self.playQueue: + return + + self.playQueue.selectedId = track.playQueueItemID or None + + @property + def trueTime(self): + try: + return self.player.getTime() + except: + return self.player.currentTime + + def stampCurrentTime(self): + try: + self.player.currentTime = self.player.getTime() + except RuntimeError: # Not playing + pass + + def onMonitorInit(self): + self.extractTrackInfo() + self.updateNowPlaying(state='playing') + + def onPlayBackStarted(self): + self.player.lastPlayWasBGM = False + self.updatePlayQueue(delay=True) + self.extractTrackInfo() + self.updateNowPlaying(state='playing') + + def onPlayBackResumed(self): + self.updateNowPlaying(state='playing') + + def onPlayBackPaused(self): + self.updateNowPlaying(state='paused') + + def onPlayBackStopped(self): + self.updatePlayQueue() + self.updateNowPlaying(state='stopped') + self.finish() + + def onPlayBackEnded(self): + self.updatePlayQueue() + self.updateNowPlaying(state='stopped') + self.finish() + + def onPlayBackFailed(self): + return True + + def finish(self): + self.player.trigger('session.ended') + util.setGlobalProperty('track.ID', '') + + def tick(self): + if not self.player.isPlayingAudio() or util.MONITOR.abortRequested(): + return + + self.stampCurrentTime() + self.updateNowPlaying(force=True) + + +class BGMPlayerHandler(BasePlayerHandler): + def __init__(self, player, rating_key): + BasePlayerHandler.__init__(self, player) + self.timelineType = 'music' + self.currentlyPlaying = rating_key + util.setGlobalProperty('track.ID', '') + util.setGlobalProperty('theme_playing', '1') + + self.oldVolume = util.rpc.Application.GetProperties(properties=["volume"])["volume"] + + def onPlayBackStarted(self): + util.DEBUG_LOG("BGM: playing theme for %s" % self.currentlyPlaying) + self.player.bgmPlaying = True + + def _setVolume(self, vlm): + xbmc.executebuiltin("SetVolume({})".format(vlm)) + + def setVolume(self, volume=None, reset=False): + vlm = self.oldVolume if reset else volume + curVolume = self.getVolume() + + if curVolume != vlm: + util.DEBUG_LOG("BGM: {}setting volume to: {}".format("re-" if reset else "", vlm)) + self._setVolume(vlm) + else: + util.DEBUG_LOG("BGM: Volume already at {}".format(vlm)) + return + + waited = 0 + waitMax = 5 + while curVolume != vlm and waited < waitMax: + util.DEBUG_LOG("Waiting for volume to change from {} to {}".format(curVolume, vlm)) + xbmc.sleep(100) + waited += 1 + curVolume = self.getVolume() + + if waited == waitMax: + util.DEBUG_LOG("BGM: Timeout setting volume to {} (is: {}). Might have been externally changed in the " + "meantime".format(vlm, self.getVolume())) + + def resetVolume(self): + self.setVolume(reset=True) + + def onPlayBackStopped(self): + util.DEBUG_LOG("BGM: stopped theme for {}".format(self.currentlyPlaying)) + util.setGlobalProperty('theme_playing', '') + self.player.bgmPlaying = False + self.resetVolume() + + def onPlayBackEnded(self): + self.onPlayBackStopped() + + def onPlayBackFailed(self): + self.onPlayBackStopped() + + def close(self): + self.player.stopAndWait() + self.onPlayBackStopped() + + +class BGMPlayerTask(backgroundthread.Task): + def setup(self, source, player, *args, **kwargs): + self.source = source + self.player = player + return self + + def cancel(self): + self.player.stopAndWait() + self.player = None + backgroundthread.Task.cancel(self) + + def run(self): + if self.isCanceled(): + return + + self.player.play(self.source, windowed=True) + + +class PlexPlayer(xbmc.Player, signalsmixin.SignalsMixin): + STATE_STOPPED = "stopped" + STATE_PLAYING = "playing" + STATE_PAUSED = "paused" + STATE_BUFFERING = "buffering" + + OFFSET_RE = re.compile(r'(offset=)\d+') + + def __init__(self, *args, **kwargs): + xbmc.Player.__init__(self, *args, **kwargs) + signalsmixin.SignalsMixin.__init__(self) + self.handler = AudioPlayerHandler(self) + + def init(self): + self._closed = False + self._nextItem = None + self.started = False + self.bgmPlaying = False + self.lastPlayWasBGM = False + self.BGMTask = None + self.pauseAfterPlaybackStarted = False + self.video = None + self.hasOSD = False + self.hasSeekOSD = False + self.handler = AudioPlayerHandler(self) + self.playerObject = None + self.currentTime = 0 + self.thread = None + self.ignoreStopEvents = False + if xbmc.getCondVisibility('Player.HasMedia'): + self.started = True + self.resume = False + self.open() + + return self + + def open(self): + self._closed = False + self.monitor() + + def close(self, shutdown=False): + self._closed = True + + def reset(self): + self.video = None + self.started = False + self.bgmPlaying = False + self.playerObject = None + self.pauseAfterPlaybackStarted = False + self.ignoreStopEvents = False + #self.handler = AudioPlayerHandler(self) + self.currentTime = 0 + + def control(self, cmd): + if cmd == 'play': + self.pauseAfterPlaybackStarted = False + util.DEBUG_LOG('Player - Control: Command=Play') + if xbmc.getCondVisibility('Player.Paused | !Player.Playing'): + util.DEBUG_LOG('Player - Control: Playing') + xbmc.executebuiltin('PlayerControl(Play)') + elif cmd == 'pause': + util.DEBUG_LOG('Player - Control: Command=Pause') + if not xbmc.getCondVisibility('Player.Paused'): + util.DEBUG_LOG('Player - Control: Pausing') + xbmc.executebuiltin('PlayerControl(Play)') + + @property + def playState(self): + if xbmc.getCondVisibility('Player.Playing'): + return self.STATE_PLAYING + elif xbmc.getCondVisibility('Player.Caching'): + return self.STATE_BUFFERING + elif xbmc.getCondVisibility('Player.Paused'): + return self.STATE_PAUSED + + return self.STATE_STOPPED + + def videoIsFullscreen(self): + return xbmc.getCondVisibility('VideoPlayer.IsFullscreen') + + def currentTrack(self): + if self.handler.media and self.handler.media.type == 'track': + return self.handler.media + return None + + def playAt(self, path, ms): + """ + Plays the video specified by path. + Optionally set the start position with h,m,s,ms keyword args. + """ + seconds = ms / 1000.0 + + h = int(seconds / 3600) + m = int((seconds % 3600) / 60) + s = int(seconds % 60) + ms = int((seconds % 1) * 1000) + + kodijsonrpc.rpc.Player.Open( + item={'file': path}, + options={'resume': {'hours': h, 'minutes': m, 'seconds': s, 'milliseconds': ms}} + ) + + def play(self, *args, **kwargs): + self.started = False + xbmc.Player.play(self, *args, **kwargs) + + def playBackgroundMusic(self, source, volume, rating_key, *args, **kwargs): + if self.isPlaying(): + if not self.lastPlayWasBGM: + return + + else: + # don't re-queue the currently playing theme + if self.handler.currentlyPlaying == rating_key: + return + + # cancel any currently playing theme before starting the new one + else: + self.stopAndWait() + + if self.BGMTask and self.BGMTask.isValid(): + self.BGMTask.cancel() + + self.started = False + self.handler = BGMPlayerHandler(self, rating_key) + + # store current volume if it's different from the BGM volume + curVol = self.handler.getVolume() + if volume < curVol: + util.setSetting('last_good_volume', curVol) + + self.lastPlayWasBGM = True + + self.handler.setVolume(volume) + + self.BGMTask = BGMPlayerTask().setup(source, self, *args, **kwargs) + backgroundthread.BGThreader.addTask(self.BGMTask) + + def playVideo(self, video, resume=False, force_update=False, session_id=None, handler=None): + if self.bgmPlaying: + self.stopAndWait() + + self.handler = handler if handler and isinstance(handler, SeekPlayerHandler) \ + else SeekPlayerHandler(self, session_id) + + self.video = video + self.resume = resume + self.open() + self._playVideo(resume and video.viewOffset.asInt() or 0, force_update=force_update) + + def getOSSPathHint(self, meta): + # only hint the path one folder above for a movie, two folders above for TV + try: + head1, tail1 = os.path.split(meta.path) + head2, tail2 = os.path.split(head1) + if self.video.type == "episode": + head3, tail3 = os.path.split(head2) + cleaned_path = os.path.join(tail3, tail2, tail1) + else: + cleaned_path = os.path.join(tail2, tail1) + except: + cleaned_path = "" + return cleaned_path + + def _playVideo(self, offset=0, seeking=0, force_update=False, playerObject=None): + self.trigger('new.video', video=self.video) + self.trigger( + 'change.background', + url=self.video.defaultArt.asTranscodedImageURL(1920, 1080, opacity=60, background=colors.noAlpha.Background) + ) + try: + if not playerObject: + self.playerObject = plexplayer.PlexPlayer(self.video, offset, forceUpdate=force_update) + self.playerObject.build() + self.playerObject = self.playerObject.getServerDecision() + except plexplayer.DecisionFailure as e: + util.showNotification(e.reason, header=util.T(32448, 'Playback Failed!')) + return + except: + util.ERROR(notify=True) + return + + meta = self.playerObject.metadata + + # Kodi 19 will try to look for subtitles in the directory containing the file. '/' and `/file.mkv` both point + # to the file, and Kodi will happily try to read the whole file without recognizing it isn't a directory. + # To get around that, we omit the filename here since it is unnecessary. + url = meta.streamUrls[0].replace("file.mkv", "").replace("file.mp4", "") + + bifURL = self.playerObject.getBifUrl() + util.DEBUG_LOG('Playing URL(+{1}ms): {0}{2}'.format(plexnetUtil.cleanToken(url), offset, bifURL and ' - indexed' or '')) + + self.ignoreStopEvents = True + self.stopAndWait() # Stop before setting up the handler to prevent player events from causing havoc + self.ignoreStopEvents = False + + self.handler.setup(self.video.duration.asInt(), meta, offset, bifURL, title=self.video.grandparentTitle, + title2=self.video.title, seeking=seeking, chapters=self.video.chapters) + + # try to get an early intro offset so we can skip it if necessary + introOffset = None + if not offset: + # in case we're transcoded, instruct the marker handler to set the marker a skipped, so we don't re-skip it + # after seeking + probOff = self.handler.getIntroOffset(offset, setSkipped=meta.isTranscoded) + if probOff: + introOffset = probOff + + if meta.isTranscoded: + self.handler.mode = self.handler.MODE_RELATIVE + + if introOffset: + # cheat our way into an early intro skip by modifying the offset in the stream URL + util.DEBUG_LOG("Immediately seeking behind intro: {}".format(introOffset)) + url = self.OFFSET_RE.sub(r"\g<1>{}".format(introOffset // 1000), url) + self.handler.dialog.baseOffset = introOffset + + # probably not necessary + meta.playStart = introOffset // 1000 + else: + if offset: + self.handler.seekOnStart = meta.playStart * 1000 + elif introOffset: + util.DEBUG_LOG("Seeking behind intro after playstart: {}".format(introOffset)) + self.handler.seekOnStart = introOffset + + self.handler.mode = self.handler.MODE_ABSOLUTE + + url = util.addURLParams(url, { + 'X-Plex-Client-Profile-Name': 'Generic', + 'X-Plex-Client-Identifier': plexapp.util.INTERFACE.getGlobal('clientIdentifier') + }) + li = xbmcgui.ListItem(self.video.title, path=url) + vtype = self.video.type if self.video.type in ('movie', 'episode', 'musicvideo') else 'video' + + util.setGlobalProperty("current_path", self.getOSSPathHint(meta), base='videoinfo.{0}') + util.setGlobalProperty("current_size", str(meta.size), base='videoinfo.{0}') + li.setInfo('video', { + 'mediatype': vtype, + 'title': self.video.title, + 'originaltitle': self.video.title, + 'tvshowtitle': self.video.grandparentTitle, + 'episode': vtype == "episode" and self.video.index.asInt() or '', + 'season': vtype == "episode" and self.video.parentIndex.asInt() or '', + #'year': self.video.year.asInt(), + 'plot': self.video.summary, + 'path': meta.path, + 'size': meta.size, + }) + li.setArt({ + 'poster': self.video.defaultThumb.asTranscodedImageURL(347, 518), + 'fanart': self.video.defaultArt.asTranscodedImageURL(1920, 1080), + 'thumb': self.video.defaultThumb.asTranscodedImageURL(256, 256), + }) + + self.play(url, li) + + def playVideoPlaylist(self, playlist, resume=False, handler=None, session_id=None): + if self.bgmPlaying: + self.stopAndWait() + + if handler and isinstance(handler, SeekPlayerHandler): + self.handler = handler + else: + self.handler = SeekPlayerHandler(self, session_id) + + self.handler.playlist = playlist + if playlist.isRemote: + self.handler.playQueue = playlist + self.video = playlist.current() + self.video.softReload(includeChapters=1) + self.resume = resume + self.open() + self._playVideo(resume and self.video.viewOffset.asInt() or 0, seeking=handler and handler.SEEK_PLAYLIST or 0, force_update=True) + + # def createVideoListItem(self, video, index=0): + # url = 'plugin://script.plex/play?{0}'.format(base64.urlsafe_b64encode(video.serialize())) + # li = xbmcgui.ListItem(self.video.title, path=url, thumbnailImage=self.video.defaultThumb.asTranscodedImageURL(256, 256)) + # vtype = self.video.type if self.video.vtype in ('movie', 'episode', 'musicvideo') else 'video' + # li.setInfo('video', { + # 'mediatype': vtype, + # 'playcount': index, + # 'title': video.title, + # 'tvshowtitle': video.grandparentTitle, + # 'episode': video.index.asInt(), + # 'season': video.parentIndex.asInt(), + # 'year': video.year.asInt(), + # 'plot': video.summary + # }) + # li.setArt({ + # 'poster': self.video.defaultThumb.asTranscodedImageURL(347, 518), + # 'fanart': self.video.defaultArt.asTranscodedImageURL(1920, 1080), + # }) + + # return url, li + + def playAudio(self, track, fanart=None, **kwargs): + if self.bgmPlaying: + self.stopAndWait() + + self.handler = AudioPlayerHandler(self) + url, li = self.createTrackListItem(track, fanart) + self.stopAndWait() + self.play(url, li, **kwargs) + + def playAlbum(self, album, startpos=-1, fanart=None, **kwargs): + if self.bgmPlaying: + self.stopAndWait() + + self.handler = AudioPlayerHandler(self) + plist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) + plist.clear() + index = 1 + for track in album.tracks(): + url, li = self.createTrackListItem(track, fanart, index=index) + plist.add(url, li) + index += 1 + xbmc.executebuiltin('PlayerControl(RandomOff)') + self.stopAndWait() + self.play(plist, startpos=startpos, **kwargs) + + def playAudioPlaylist(self, playlist, startpos=-1, fanart=None, **kwargs): + if self.bgmPlaying: + self.stopAndWait() + + self.handler = AudioPlayerHandler(self) + plist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) + plist.clear() + index = 1 + for track in playlist.items(): + url, li = self.createTrackListItem(track, fanart, index=index) + plist.add(url, li) + index += 1 + + if playlist.isRemote: + self.handler.setPlayQueue(playlist) + else: + if playlist.startShuffled: + plist.shuffle() + xbmc.executebuiltin('PlayerControl(RandomOn)') + else: + xbmc.executebuiltin('PlayerControl(RandomOff)') + self.stopAndWait() + self.play(plist, startpos=startpos, **kwargs) + + def createTrackListItem(self, track, fanart=None, index=0): + data = base64.urlsafe_b64encode(track.serialize().encode("utf8")).decode("utf8") + url = 'plugin://script.plexmod/play?{0}'.format(data) + li = xbmcgui.ListItem(track.title, path=url) + li.setInfo('music', { + 'artist': six.text_type(track.originalTitle or track.grandparentTitle), + 'title': six.text_type(track.title), + 'album': six.text_type(track.parentTitle), + 'discnumber': track.parentIndex.asInt(), + 'tracknumber': track.get('index').asInt(), + 'duration': int(track.duration.asInt() / 1000), + 'playcount': index, + 'comment': 'PLEX-{0}:{1}'.format(track.ratingKey, data) + }) + art = fanart or track.defaultArt + li.setArt({ + 'fanart': art.asTranscodedImageURL(1920, 1080), + 'landscape': util.backgroundFromArt(art), + 'thumb': track.defaultThumb.asTranscodedImageURL(800, 800), + }) + if fanart: + li.setArt({'fanart': fanart}) + return (url, li) + + def onPrePlayStarted(self): + util.DEBUG_LOG('Player - PRE-PLAY; handler: %r' % self.handler) + self.trigger('preplay.started') + if not self.handler: + return + self.handler.onPrePlayStarted() + + def onPlayBackStarted(self): + util.DEBUG_LOG('Player - STARTED') + self.trigger('playback.started') + self.started = True + if self.pauseAfterPlaybackStarted: + self.control('pause') + self.pauseAfterPlaybackStarted = False + + if not self.handler: + return + self.handler.onPlayBackStarted() + + def onAVChange(self): + util.DEBUG_LOG('Player - AVChange') + if not self.handler: + return + self.handler.onAVChange() + + def onAVStarted(self): + util.DEBUG_LOG('Player - AVStarted: {}'.format(self.handler)) + self.trigger('av.started') + if not self.handler: + return + self.handler.onAVStarted() + + def onPlayBackPaused(self): + util.DEBUG_LOG('Player - PAUSED') + if not self.handler: + return + self.handler.onPlayBackPaused() + + def onPlayBackResumed(self): + util.DEBUG_LOG('Player - RESUMED') + if not self.handler: + return + + self.handler.onPlayBackResumed() + + def onPlayBackStopped(self): + util.DEBUG_LOG('Player - STOPPED' + (not self.started and ': FAILED' or '')) + if self.ignoreStopEvents: + return + + if not self.started: + self.onPlayBackFailed() + + if not self.handler: + return + self.handler.onPlayBackStopped() + + def onPlayBackEnded(self): + util.DEBUG_LOG('Player - ENDED' + (not self.started and ': FAILED' or '')) + if self.ignoreStopEvents: + return + + if not self.started: + self.onPlayBackFailed() + + if not self.handler: + return + self.handler.onPlayBackEnded() + + def onPlayBackSeek(self, time, offset): + util.DEBUG_LOG('Player - SEEK: %i' % offset) + if not self.handler: + return + self.handler.onPlayBackSeek(time, offset) + + def onPlayBackFailed(self): + util.DEBUG_LOG('Player - FAILED: {}'.format(self.handler)) + if not self.handler: + return + + if self.handler.onPlayBackFailed(): + util.showNotification(util.T(32448, 'Playback Failed!')) + self.stopAndWait() + self.close() + # xbmcgui.Dialog().ok('Failed', 'Playback failed') + + def onVideoWindowOpened(self): + util.DEBUG_LOG('Player: Video window opened') + try: + self.handler.onVideoWindowOpened() + except: + util.ERROR() + + def onVideoWindowClosed(self): + util.DEBUG_LOG('Player: Video window closed') + try: + self.handler.onVideoWindowClosed() + # self.stop() + except: + util.ERROR() + + def onVideoOSD(self): + util.DEBUG_LOG('Player: Video OSD opened') + try: + self.handler.onVideoOSD() + except: + util.ERROR() + + def onSeekOSD(self): + util.DEBUG_LOG('Player: Seek OSD opened') + try: + self.handler.onSeekOSD() + except: + util.ERROR() + + def stopAndWait(self): + if self.isPlaying(): + util.DEBUG_LOG('Player: Stopping and waiting...') + self.stop() + while not util.MONITOR.waitForAbort(0.1) and self.isPlaying(): + pass + util.MONITOR.waitForAbort(0.2) + util.DEBUG_LOG('Player: Stopping and waiting...Done') + + def monitor(self): + if not self.thread or not self.thread.is_alive(): + self.thread = threading.Thread(target=self._monitor, name='PLAYER:MONITOR') + self.thread.start() + + def _monitor(self): + try: + while not util.MONITOR.abortRequested() and not self._closed: + if not self.isPlaying(): + util.DEBUG_LOG('Player: Idling...') + + while not self.isPlaying() and not util.MONITOR.abortRequested() and not self._closed: + util.MONITOR.waitForAbort(0.1) + + if self.isPlayingVideo(): + util.DEBUG_LOG('Monitoring video...') + self._videoMonitor() + elif self.isPlayingAudio(): + util.DEBUG_LOG('Monitoring audio...') + self._audioMonitor() + elif self.isPlaying(): + util.DEBUG_LOG('Monitoring pre-play...') + + # note: this might never be triggered depending on how fast the video playback starts. + # don't rely on it in any way. + self._preplayMonitor() + + self.handler.close() + self.close() + util.DEBUG_LOG('Player: Closed') + finally: + self.trigger('session.ended') + + def _preplayMonitor(self): + self.onPrePlayStarted() + while self.isPlaying() and not self.isPlayingVideo() and not self.isPlayingAudio() and not util.MONITOR.abortRequested() and not self._closed: + util.MONITOR.waitForAbort(0.1) + + if not self.isPlayingVideo() and not self.isPlayingAudio(): + self.onPlayBackFailed() + + def _videoMonitor(self): + hasFullScreened = False + + ct = 0 + while self.isPlayingVideo() and not util.MONITOR.abortRequested() and not self._closed: + try: + self.currentTime = self.getTime() + except RuntimeError: + break + + util.MONITOR.waitForAbort(0.1) + if xbmc.getCondVisibility('Window.IsActive(videoosd)'): + if not self.hasOSD: + self.hasOSD = True + self.onVideoOSD() + else: + self.hasOSD = False + + if xbmc.getCondVisibility('Window.IsActive(seekbar)'): + if not self.hasSeekOSD: + self.hasSeekOSD = True + self.onSeekOSD() + else: + self.hasSeekOSD = False + + if xbmc.getCondVisibility('VideoPlayer.IsFullscreen'): + if not hasFullScreened: + hasFullScreened = True + self.onVideoWindowOpened() + elif hasFullScreened and not xbmc.getCondVisibility('Window.IsVisible(busydialog)'): + hasFullScreened = False + self.onVideoWindowClosed() + + ct += 1 + if ct > 9: + ct = 0 + self.handler.tick() + + if hasFullScreened: + self.onVideoWindowClosed() + + def _audioMonitor(self): + self.started = True + self.handler.onMonitorInit() + ct = 0 + while self.isPlayingAudio() and not util.MONITOR.abortRequested() and not self._closed: + try: + self.currentTime = self.getTime() + except RuntimeError: + break + + util.MONITOR.waitForAbort(0.1) + + ct += 1 + if ct > 9: + ct = 0 + self.handler.tick() + + +def shutdown(): + global PLAYER + PLAYER.close(shutdown=True) + del PLAYER + + +PLAYER = PlexPlayer().init() diff --git a/script.plexmod/lib/plex.py b/script.plexmod/lib/plex.py new file mode 100644 index 000000000..0576f537b --- /dev/null +++ b/script.plexmod/lib/plex.py @@ -0,0 +1,454 @@ +from __future__ import absolute_import +import sys +import platform +import traceback +import uuid +import json +import threading +import time +import requests +import six + +from kodi_six import xbmc, xbmcaddon + +from plexnet import plexapp, myplex, util as plexnet_util, asyncadapter, http as pnhttp + +from .playback_utils import PlaybackManager +from . windows.settings import PlayedThresholdSetting +from . import util +from six.moves import range + +if six.PY2: + _Event = threading._Event +else: + _Event = threading.Event + + +class PlexTimer(plexapp.util.Timer): + def shouldAbort(self): + return util.MONITOR.abortRequested() + + +def abortFlag(): + return util.MONITOR.abortRequested() + + +plexapp.util.setTimer(PlexTimer) +plexapp.setAbortFlagFunction(abortFlag) + +maxVideoRes = plexapp.Res((3840, 2160)) # INTERFACE.globals["supports4k"] and plexapp.Res((3840, 2160)) or plexapp.Res((1920, 1080)) + +CLIENT_ID = util.getSetting('client.ID') +if not CLIENT_ID: + CLIENT_ID = str(uuid.uuid4()) + util.setSetting('client.ID', CLIENT_ID) + + +def defaultUserAgent(): + """Return a string representing the default user agent.""" + _implementation = platform.python_implementation() + + if _implementation == 'CPython': + _implementation_version = platform.python_version() + elif _implementation == 'PyPy': + _implementation_version = '%s.%s.%s' % (sys.pypy_version_info.major, + sys.pypy_version_info.minor, + sys.pypy_version_info.micro) + if sys.pypy_version_info.releaselevel != 'final': + _implementation_version = ''.join([_implementation_version, sys.pypy_version_info.releaselevel]) + elif _implementation == 'Jython': + _implementation_version = platform.python_version() # Complete Guess + elif _implementation == 'IronPython': + _implementation_version = platform.python_version() # Complete Guess + else: + _implementation_version = 'Unknown' + + try: + p_system = platform.system() + p_release = platform.release() + except IOError: + p_system = 'Unknown' + p_release = 'Unknown' + + return " ".join(['%s/%s' % ('PM4K', util.ADDON.getAddonInfo('version')), + '%s/%s' % ('Kodi', xbmc.getInfoLabel('System.BuildVersion').replace(' ', '-')), + '%s/%s' % (_implementation, _implementation_version), + '%s/%s' % (p_system, p_release)]) + + +class PlexInterface(plexapp.AppInterface): + _regs = { + None: {}, + } + _globals = { + 'platform': 'Kodi', + 'appVersionStr': util.ADDON.getAddonInfo('version'), + 'clientIdentifier': CLIENT_ID, + 'platformVersion': xbmc.getInfoLabel('System.BuildVersion'), + 'product': 'PM4K', + 'provides': 'player', + 'device': util.getPlatform() or plexapp.PLATFORM, + 'model': 'Unknown', + 'friendlyName': util.rpc.Settings.GetSettingValue(setting='services.devicename').get('value') or 'Kodi', + 'supports1080p60': True, + 'vp9Support': True, + 'audioChannels': '2.0', + 'transcodeVideoQualities': [ + "10", "20", "30", "30", "40", "60", "60", "75", "100", "60", "75", "90", "100", "100" + ], + 'transcodeVideoResolutions': [ + plexapp.Res((220, 180)), + plexapp.Res((220, 128)), + plexapp.Res((284, 160)), + plexapp.Res((420, 240)), + plexapp.Res((576, 320)), + plexapp.Res((720, 480)), + plexapp.Res((1024, 768)), + plexapp.Res((1280, 720)), + plexapp.Res((1280, 720)), + maxVideoRes, maxVideoRes, maxVideoRes, maxVideoRes, maxVideoRes + ], + 'transcodeVideoBitrates': [ + "64", "96", "208", "320", "720", "1500", "2000", "3000", "4000", "8000", "10000", "12000", "20000", "400000" + ], + 'deviceInfo': plexapp.DeviceInfo() + } + + bingeModeManager = None + + def getPreference(self, pref, default=None): + if pref == 'manual_connections': + return self.getManualConnections() + else: + return util.getSetting(pref, default) + + def getManualConnections(self): + conns = [] + for i in range(2): + ip = util.getSetting('manual_ip_{0}'.format(i)) + if not ip: + continue + port = util.getSetting('manual_port_{0}'.format(i), 32400) + conns.append({'connection': ip, 'port': port}) + return json.dumps(conns) + + def setPreference(self, pref, value): + util.setSetting(pref, value) + + def getRegistry(self, reg, default=None, sec=None): + if sec == 'myplex' and reg == 'MyPlexAccount': + ret = util.getSetting('{0}.{1}'.format(sec, reg), default) + if ret: + return ret + return json.dumps({'authToken': util.getSetting('auth.token')}) + else: + return util.getSetting('{0}.{1}'.format(sec, reg), default) + + def setRegistry(self, reg, value, sec=None): + util.setSetting('{0}.{1}'.format(sec, reg), value) + + def clearRegistry(self, reg, sec=None): + util.setSetting('{0}.{1}'.format(sec, reg), '') + + def addInitializer(self, sec): + pass + + def clearInitializer(self, sec): + pass + + def getGlobal(self, glbl, default=None): + if glbl == 'transcodeVideoResolutions': + maxres = self.getPreference('allow_4k', True) and plexapp.Res((3840, 2160)) or plexapp.Res((1920, 1080)) + self._globals['transcodeVideoResolutions'][-5:] = [maxres] * 5 + elif glbl == 'audioChannels': + try: + self._globals['audioChannels'] = \ + util.CHANNELMAPPING[util.rpc.Settings.GetSettingValue(setting='audiooutput.channels').get('value')] + except: + util.DEBUG_LOG("Limiting audio channel definition to 2.0 due to error: %s" % traceback.format_exc()) + self._globals['audioChannels'] = "2.0" + + return self._globals.get(glbl, default) + + def getCapabilities(self): + return '' + + def LOG(self, msg): + util.DEBUG_LOG('API: {0}'.format(msg)) + + def DEBUG_LOG(self, msg): + self.LOG('DEBUG: {0}'.format(msg)) + + def WARN_LOG(self, msg): + self.LOG('WARNING: {0}'.format(msg)) + + def ERROR_LOG(self, msg): + self.LOG('ERROR: {0}'.format(msg)) + + def ERROR(self, msg=None, err=None): + if err: + self.LOG('ERROR: {0} - {1}'.format(msg, getattr(err, "message", "Unknown Error"))) + else: + util.ERROR() + + def supportsAudioStream(self, codec, channels): + return True + # if codec = invalid then return true + + # canDownmix = (m.globals["audioDownmix"][codec] <> invalid) + # supportsSurroundSound = m.SupportsSurroundSound() + + # if not supportsSurroundSound and canDownmix then + # maxChannels = m.globals["audioDownmix"][codec] + # else + # maxChannels = firstOf(m.globals["audioDecoders"][codec], 0) + # end if + + # if maxChannels > 2 and not canDownmix and not supportsSurroundSound then + # ' It's a surround sound codec and we can't do surround sound + # supported = false + # else if maxChannels = 0 or maxChannels < channels then + # ' The codec is either unsupported or can't handle the requested channels + # supported = false + # else + # supported = true + + # return supported + + def supportsSurroundSound(self): + return True + + def getQualityIndex(self, qualityType): + if qualityType == self.QUALITY_LOCAL: + return self.getPreference("local_quality", 13) + elif qualityType == self.QUALITY_ONLINE: + return self.getPreference("online_quality", 13) + else: + return self.getPreference("remote_quality", 13) + + def getMaxResolution(self, quality_type, allow4k=False): + qualityIndex = self.getQualityIndex(quality_type) + + if qualityIndex >= 9: + if self.getPreference('allow_4k', True): + return allow4k and 2160 or 1088 + else: + return 1088 + elif qualityIndex >= 6: + return 720 + elif qualityIndex >= 5: + return 480 + else: + return 360 + + def getThemeMusicValue(self): + index = 10 - self.getPreference("theme_music", 5) + if index > 0: + return index * 10 + return 0 + + def getPlayedThresholdValue(self): + values = list(reversed(PlayedThresholdSetting.options)) + return int(values[self.getPreference("played_threshold", 1)].replace(" %", "")) / 100.0 + + +def onSmartDiscoverLocalChange(value=None, **kwargs): + plexnet_util.CHECK_LOCAL = value + plexapp.refreshResources(True) + + +def onPreferLANChange(value=None, **kwargs): + plexnet_util.LOCAL_OVER_SECURE = value + plexapp.refreshResources(True) + + +def onPreferLocalChange(**kwargs): + plexapp.refreshResources(True) + + +def onManualIPChange(**kwargs): + plexapp.refreshResources(True) + + +plexapp.util.setInterface(PlexInterface()) +plexapp.util.INTERFACE.playbackManager = PlaybackManager() +plexapp.util.APP.on('change:smart_discover_local', onSmartDiscoverLocalChange) +plexapp.util.APP.on('change:prefer_local', onPreferLANChange) +plexapp.util.APP.on('change:same_network', onPreferLocalChange) +plexapp.util.APP.on('change:manual_ip_0', onManualIPChange) +plexapp.util.APP.on('change:manual_ip_1', onManualIPChange) +plexapp.util.APP.on('change:manual_port_0', onManualIPChange) +plexapp.util.APP.on('change:manual_port_1', onManualIPChange) + +plexapp.util.CHECK_LOCAL = util.getSetting('smart_discover_local', True) +plexapp.util.LOCAL_OVER_SECURE = util.getSetting('prefer_local', False) + +# set requests timeout +TIMEOUT = float(util.advancedSettings.requestsTimeout) +CONNCHECK_TIMEOUT = float(util.advancedSettings.connCheckTimeout) +plexapp.util.TIMEOUT = TIMEOUT +plexapp.util.CONN_CHECK_TIMEOUT = asyncadapter.AsyncTimeout(CONNCHECK_TIMEOUT).setConnectTimeout(CONNCHECK_TIMEOUT) +plexapp.util.LAN_REACHABILITY_TIMEOUT = util.advancedSettings.localReachTimeout / 1000.0 +pnhttp.DEFAULT_TIMEOUT = asyncadapter.AsyncTimeout(TIMEOUT).setConnectTimeout(TIMEOUT) +asyncadapter.DEFAULT_TIMEOUT = pnhttp.DEFAULT_TIMEOUT +plexapp.util.ACCEPT_LANGUAGE = util.ACCEPT_LANGUAGE_CODE +plexapp.setUserAgent(defaultUserAgent()) + + +class CallbackEvent(plexapp.util.CompatEvent): + def __init__(self, context, signal, timeout=15, *args, **kwargs): + plexnet_util.Event.__init__(self, *args, **kwargs) + self.start = time.time() + self.context = context + self.signal = signal + self.timeout = timeout + self.context.on(self.signal, self.set) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.wait() + + def __repr__(self): + return '<{0}:{1}>'.format(self.__class__.__name__, self.signal) + + def set(self, **kwargs): + plexnet_util.Event.set(self) + + def wait(self): + if not plexnet_util.Event.wait(self, self.timeout): + util.DEBUG_LOG('{0}: TIMED-OUT'.format(self)) + self.close() + + def triggeredOrTimedOut(self, timeout=None): + try: + if time.time() - self.start > self.timeout: + util.DEBUG_LOG('{0}: TIMED-OUT'.format(self)) + return True + + if timeout: + plexnet_util.Event.wait(self, timeout) + finally: + return self.isSet() + + def close(self): + self.set() + self.context.off(self.signal, self.set) + + +def init(): + util.DEBUG_LOG('Initializing...') + + with CallbackEvent(plexapp.util.APP, 'init'): + plexapp.init() + util.DEBUG_LOG('Waiting for account initialization...') + + util.DEBUG_LOG('Account initialized: {}'.format(plexapp.ACCOUNT.ID)) + + retry = True + + while retry: + retry = False + if not plexapp.ACCOUNT.authToken: + util.DEBUG_LOG("No auth token, authorizing") + token = authorize() + + if not token: + util.DEBUG_LOG('FAILED TO AUTHORIZE') + return False + + with CallbackEvent(plexapp.util.APP, 'account:response'): + plexapp.ACCOUNT.validateToken(token) + util.DEBUG_LOG('Waiting for account initialization...') + + # if not PLEX: + # util.messageDialog('Connection Error', u'Unable to connect to any servers') + # util.DEBUG_LOG('SIGN IN: Failed to connect to any servers') + # return False + + # util.DEBUG_LOG('SIGN IN: Connected to server: {0} - {1}'.format(PLEX.friendlyName, PLEX.baseuri)) + success = requirePlexPass() + if success == 'RETRY': + retry = True + continue + + return success + + +def requirePlexPass(): + return True + # if not plexapp.ACCOUNT.hasPlexPass(): + # from windows import signin, background + # background.setSplash(False) + # w = signin.SignInPlexPass.open() + # retry = w.retry + # del w + # util.DEBUG_LOG('PlexPass required. Signing out...') + # plexapp.ACCOUNT.signOut() + # plexapp.SERVERMANAGER.clearState() + # if retry: + # return 'RETRY' + # else: + # return False + + # return True + + +def authorize(): + from .windows import signin, background + + background.setSplash(False) + + back = signin.Background.create() + + pre = signin.PreSignInWindow.open() + try: + if not pre.doSignin: + return None + finally: + del pre + + try: + while True: + pinLoginWindow = signin.PinLoginWindow.create() + try: + pl = myplex.PinLogin() + except requests.ConnectionError: + util.ERROR() + util.messageDialog(util.T(32427, 'Failed'), util.T(32449, 'Sign-in failed. Cound not connect to plex.tv')) + return + + pinLoginWindow.setPin(pl.pin) + + try: + pl.startTokenPolling() + while not pl.finished(): + if pinLoginWindow.abort: + util.DEBUG_LOG('SIGN IN: Pin login aborted') + pl.abort() + return None + xbmc.sleep(100) + else: + if not pl.expired(): + if pl.authenticationToken: + pinLoginWindow.setLinking() + return pl.authenticationToken + else: + return None + finally: + pinLoginWindow.doClose() + del pinLoginWindow + + if pl.expired(): + util.DEBUG_LOG('SIGN IN: Pin expired') + expiredWindow = signin.ExpiredWindow.open() + try: + if not expiredWindow.refresh: + util.DEBUG_LOG('SIGN IN: Pin refresh aborted') + return None + finally: + del expiredWindow + finally: + back.doClose() + del back diff --git a/script.plexmod/lib/util.py b/script.plexmod/lib/util.py new file mode 100644 index 000000000..2585380b5 --- /dev/null +++ b/script.plexmod/lib/util.py @@ -0,0 +1,978 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +import gc +import sys +import re +import binascii +import json +import threading +import math +import time +import datetime +import contextlib +import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error +import six +import os + +from .kodijsonrpc import rpc +from kodi_six import xbmc +from kodi_six import xbmcgui +from kodi_six import xbmcaddon +from kodi_six import xbmcvfs + +from . import colors +from .exceptions import NoDataException +from plexnet import signalsmixin, plexapp + +DEBUG = True +_SHUTDOWN = False + +ADDON = xbmcaddon.Addon() + +SETTINGS_LOCK = threading.Lock() + +_build = None +# buildversion looks like: XX.X[-TAG] (a+.b+.c+) (.+); there are kodi builds that don't set the build version +sys_ver = xbmc.getInfoLabel('System.BuildVersion') +_ver = sys_ver +if ' ' in sys_ver and '(' in sys_ver: + _ver, _build = sys_ver.split()[:2] + +_splitver = _ver.split(".") +KODI_VERSION_MAJOR, KODI_VERSION_MINOR = int(_splitver[0].split("-")[0].strip()), \ + int(_splitver[1].split("-")[0].strip()) + +_bmajor, _bminor, _bpatch = (KODI_VERSION_MAJOR, KODI_VERSION_MINOR, 0) +if _build: + _bmajor, _bminor, _bpatch = _build[1:-1].split(".") +else: + xbmc.log('script.plex: Couldn\'t determine build version, falling back to Kodi version', xbmc.LOGINFO) + +# calculate a comparable build number +KODI_BUILD_NUMBER = int("{0}{1:02d}{2:03d}".format(_bmajor, int(_bminor), int(_bpatch))) +xbmc.log('script.plex: Kodi {0}.{1} (build {2})'.format(KODI_VERSION_MAJOR, KODI_VERSION_MINOR, KODI_BUILD_NUMBER), + xbmc.LOGINFO) + + +if KODI_VERSION_MAJOR > 18: + translatePath = xbmcvfs.translatePath +else: + translatePath = xbmc.translatePath + +PROFILE = translatePath(ADDON.getAddonInfo('profile')) + + +def getChannelMapping(): + data = rpc.Settings.GetSettings(filter={"section": "system", "category": "audio"})["settings"] + return list(filter(lambda i: i["id"] == "audiooutput.channels", data))[0]["options"] + + +# retrieve labels for mapping audio channel settings values +try: + CHANNELMAPPING = dict((t["value"], t["label"]) for t in getChannelMapping()) +except: + CHANNELMAPPING = None + + +def getLanguageCode(add_def=None): + data = rpc.Settings.GetSettingValue(setting='locale.language')['value'].replace('resource.language.', '') + lang = "" + if "_" in data: + base, variant = data.split("_") + lang += "{}-{},{}".format(base, variant.upper(), base) + else: + lang = data + if add_def and lang not in add_def: + lang += ",{}".format(add_def) + return lang + + +try: + ACCEPT_LANGUAGE_CODE = getLanguageCode(add_def='en-US,en') +except: + ACCEPT_LANGUAGE_CODE = 'en-US,en' + + +def getSetting(key, default=None): + with SETTINGS_LOCK: + setting = ADDON.getSetting(key) + return _processSetting(setting, default) + + +def getUserSetting(key, default=None): + if not plexapp.ACCOUNT: + return default + + key = '{}.{}'.format(key, plexapp.ACCOUNT.ID) + with SETTINGS_LOCK: + setting = ADDON.getSetting(key) + return _processSetting(setting, default) + + +def _processSetting(setting, default): + if not setting: + return default + if isinstance(default, bool): + return setting.lower() == 'true' + elif isinstance(default, float): + return float(setting) + elif isinstance(default, int): + return int(float(setting or 0)) + elif isinstance(default, list): + if setting: + return json.loads(binascii.unhexlify(setting)) + else: + return default + + return setting + + +class AdvancedSettings(object): + """ + @DynamicAttrs + """ + + _proxiedSettings = ( + ("debug", False), + ("kodi_skip_stepping", False), + ("auto_seek", True), + ("auto_seek_delay", 1), + ("dynamic_timeline_seek", False), + ("fast_back", False), + ("dynamic_backgrounds", True), + ("background_art_blur_amount2", 0), + ("background_art_opacity_amount2", 20), + ("screensaver_quiz", False), + ("postplay_always", False), + ("postplay_timeout", 16), + ("skip_intro_button_timeout", 10), + ("skip_credits_button_timeout", 10), + ("playlist_visit_media", True), + ("intro_skip_early", False), + ("show_media_ends_info", True), + ("show_media_ends_label", True), + ("background_colour", None), + ("oldprofile", False), + ("skip_intro_button_show_early_threshold1", 60), + ("requests_timeout", 5.0), + ("local_reach_timeout", 10), + ("auto_skip_offset", 2.5), + ("conn_check_timeout", 2.5), + ("postplayCancel", True), + ("skip_marker_timer_cancel", True), + ("skip_marker_timer_immediate", False), + ("low_drift_timer", True), + ("player_show_buffer", True), + ("buffer_wait_max", 120), + ("buffer_insufficient_wait", 10), + ("continue_use_thumb", True), + ("use_bg_fallback", False), + ("dbg_crossfade", True), + ("subtitle_use_extended_title", True), + ("dialog_flicker_fix", True), + ("poster_resolution_scale", 1.0), + ) + + def __init__(self): + # register every known setting camelCased as an attribute to this instance + for setting, default in self._proxiedSettings: + name_split = setting.split("_") + setattr(self, name_split[0] + ''.join(x.capitalize() or '_' for x in name_split[1:]), + getSetting(setting, default)) + + +advancedSettings = AdvancedSettings() + + +def LOG(msg, level=xbmc.LOGINFO): + xbmc.log('script.plex: {0}'.format(msg), level) + + +def DEBUG_LOG(msg): + if _SHUTDOWN: + return + + if not advancedSettings.debug and not xbmc.getCondVisibility('System.GetBool(debug.showloginfo)'): + return + + LOG(msg) + + +def ERROR(txt='', hide_tb=False, notify=False, time_ms=3000): + short = str(sys.exc_info()[1]) + if hide_tb: + xbmc.log('script.plex: ERROR: {0} - {1}'.format(txt, short), xbmc.LOGERROR) + return short + + import traceback + tb = traceback.format_exc() + xbmc.log("_________________________________________________________________________________", xbmc.LOGERROR) + xbmc.log('script.plex: ERROR: ' + txt, xbmc.LOGERROR) + for l in tb.splitlines(): + xbmc.log(' ' + l, xbmc.LOGERROR) + xbmc.log("_________________________________________________________________________________", xbmc.LOGERROR) + xbmc.log("`", xbmc.LOGERROR) + if notify: + showNotification('ERROR: {0}'.format(txt or short), time_ms=time_ms) + return short + + +def TEST(msg): + xbmc.log('---TEST: {0}'.format(msg), xbmc.LOGINFO) + + +class UtilityMonitor(xbmc.Monitor, signalsmixin.SignalsMixin): + def __init__(self, *args, **kwargs): + xbmc.Monitor.__init__(self, *args, **kwargs) + signalsmixin.SignalsMixin.__init__(self) + + def watchStatusChanged(self): + self.trigger('changed.watchstatus') + + def actionStop(self): + self.stopPlayback() + + def actionQuit(self): + LOG('OnSleep: Exit Kodi') + xbmc.executebuiltin('Quit') + + def actionReboot(self): + LOG('OnSleep: Reboot') + xbmc.restart() + + def actionShutdown(self): + LOG('OnSleep: Shutdown') + xbmc.shutdown() + + def actionHibernate(self): + LOG('OnSleep: Hibernate') + xbmc.executebuiltin('Hibernate') + + def actionSuspend(self): + LOG('OnSleep: Suspend') + xbmc.executebuiltin('Suspend') + + def actionCecstandby(self): + LOG('OnSleep: CEC Standby') + xbmc.executebuiltin('CECStandby') + + def actionLogoff(self): + LOG('OnSleep: Sign Out') + xbmc.executebuiltin('System.LogOff') + + def onNotification(self, sender, method, data): + LOG("Notification: {} {} {}".format(sender, method, data)) + if sender == 'script.plexmod' and method.endswith('RESTORE'): + from .windows import kodigui + if not kodigui.BaseFunctions.lastWinID: + ERROR("Addon never properly started, can't reactivate") + setGlobalProperty('stop_running', '1') + return + if kodigui.BaseFunctions.lastWinID > 13000: + xbmc.executebuiltin('ActivateWindow({0})'.format(kodigui.BaseFunctions.lastWinID)) + else: + ERROR("Addon never properly started, can't reactivate") + setGlobalProperty('stop_running', '1') + return + + getAdvancedSettings() + populateTimeFormat() + + elif sender == "xbmc" and method == "System.OnSleep" and getSetting('action_on_sleep', "none") != "none": + getattr(self, "action{}".format(getSetting('action_on_sleep', "none").capitalize()))() + + def stopPlayback(self): + LOG('Monitor: Stopping media playback') + xbmc.Player().stop() + + def onScreensaverActivated(self): + DEBUG_LOG("Monitor: OnScreensaverActivated") + if getSetting('player_stop_on_screensaver', True) and xbmc.Player().isPlayingVideo(): + self.stopPlayback() + + def onDPMSActivated(self): + DEBUG_LOG("Monitor: OnDPMSActivated") + #self.stopPlayback() + + +MONITOR = UtilityMonitor() + +ADV_MSIZE_RE = re.compile(r'(\d+)') +ADV_RFACT_RE = re.compile(r'(\d+)') +ADV_CACHE_RE = re.compile(r'\s*.*', re.S | re.I) + + +class KodiCacheManager(object): + """ + A pretty cheap approach at managing the section of advancedsettings.xml + + Starting with build 20.90.821 (Kodi 21.0-BETA2) a lot of caching issues have been fixed and + readfactor behaves better. We need to adjust for that. + """ + _cleanData = None + useModernAPI = False + memorySize = 20 # in MB + readFactor = 4 + defRF = 4 + defRFSM = 20 + recRFRange = (4, 10) + template = None + orig_tpl_path = os.path.join(ADDON.getAddonInfo('path'), "pm4k_cache_template.xml") + custom_tpl_path = "special://profile/pm4k_cache_template.xml" + translated_ctpl_path = translatePath(custom_tpl_path) + + # give Android a little more leeway with its sometimes weird memory management; otherwise stick with 23% of free mem + safeFactor = .20 if xbmc.getCondVisibility('System.Platform.Android') else .23 + + def __init__(self): + if KODI_BUILD_NUMBER >= 2090821: + self.memorySize = rpc.Settings.GetSettingValue(setting='filecache.memorysize')['value'] + self.readFactor = rpc.Settings.GetSettingValue(setting='filecache.readfactor')['value'] / 100.0 + if self.readFactor % 1 == 0: + self.readFactor = int(self.readFactor) + DEBUG_LOG("Not using advancedsettings.xml for cache/buffer management, we're at least Kodi 21 non-alpha") + self.useModernAPI = True + self.defRFSM = 7 + self.recRFRange = (1.5, 4) + + else: + self.load() + self.template = self.getTemplate() + + plexapp.util.APP.on('change:slow_connection', + lambda value=None, **kwargs: self.write(readFactor=value and self.defRFSM or self.defRF)) + + def getTemplate(self): + if xbmcvfs.exists(self.custom_tpl_path): + try: + f = xbmcvfs.File(self.custom_tpl_path) + data = f.read() + f.close() + if data: + return data + except: + pass + + DEBUG_LOG("Custom pm4k_cache_template.xml not found, using default") + f = xbmcvfs.File(self.orig_tpl_path) + data = f.read() + f.close() + return data + + def load(self): + try: + f = xbmcvfs.File("special://profile/advancedsettings.xml") + data = f.read() + f.close() + except: + LOG('script.plex: No advancedsettings.xml found') + else: + cachexml_match = ADV_CACHE_RE.search(data) + if cachexml_match: + cachexml = cachexml_match.group(0) + + try: + self.memorySize = int(ADV_MSIZE_RE.search(cachexml).group(1)) // 1024 // 1024 + except (ValueError, IndexError, TypeError): + DEBUG_LOG("script.plex: invalid or not found memorysize in advancedsettings.xml") + + try: + self.readFactor = int(ADV_RFACT_RE.search(cachexml).group(1)) + except (ValueError, IndexError, TypeError): + DEBUG_LOG("script.plex: invalid or not found readfactor in advancedsettings.xml") + + self._cleanData = data.replace(cachexml, "") + else: + self._cleanData = data + + def write(self, memorySize=None, readFactor=None): + memorySize = self.memorySize = memorySize if memorySize is not None else self.memorySize + readFactor = self.readFactor = readFactor if readFactor is not None else self.readFactor + + if self.useModernAPI: + # kodi cache settings have moved to Services>Caching + try: + rpc.Settings.SetSettingValue(setting='filecache.memorysize', value=self.memorySize) + rpc.Settings.SetSettingValue(setting='filecache.readfactor', value=int(self.readFactor * 100)) + except: + pass + return + + cd = self._cleanData + if not cd: + cd = "\n" + + finalxml = "{}\n".format( + cd.replace("", self.template.format(memorysize=memorySize * 1024 * 1024, + readfactor=readFactor)) + ) + + try: + f = xbmcvfs.File("special://profile/advancedsettings.xml", "w") + f.write(finalxml) + f.close() + except: + ERROR("Couldn't write advancedsettings.xml") + + def clamp16(self, x): + return x - x % 16 + + @property + def viableOptions(self): + default = list(filter(lambda x: x < self.recMax, + [16, 20, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024])) + + # add option to overcommit slightly + overcommit = [] + if xbmc.getCondVisibility('System.Platform.Android'): + overcommit.append(min(self.clamp16(int(self.free * 0.23)), 2048)) + + overcommit.append(min(self.clamp16(int(self.free * 0.26)), 2048)) + overcommit.append(min(self.clamp16(int(self.free * 0.3)), 2048)) + + # re-append current memorySize here, as recommended max might have changed + return list(sorted(list(set(default + [self.memorySize, self.recMax] + overcommit)))) + + @property + def readFactorOpts(self): + return list(sorted(list(set([1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5, 7, 10, 15, 20, 30, 50] + [self.readFactor])))) + + @property + def free(self): + return float(xbmc.getInfoLabel('System.Memory(free)')[:-2]) + + @property + def recMax(self): + freeMem = self.free + recMem = min(int(freeMem * self.safeFactor), 2048) + LOG("Free memory: {} MB, recommended max: {} MB".format(freeMem, recMem)) + return recMem + + +kcm = KodiCacheManager() + +CACHE_SIZE = kcm.memorySize + + +def T(ID, eng=''): + return ADDON.getLocalizedString(ID) + + +hasCustomBGColour = False +if KODI_VERSION_MAJOR > 18: + hasCustomBGColour = not advancedSettings.dynamicBackgrounds and advancedSettings.backgroundColour and \ + advancedSettings.backgroundColour != "-" + + +def getAdvancedSettings(): + # yes, global, hang me! + global advancedSettings + advancedSettings = AdvancedSettings() + + +def setSetting(key, value): + with SETTINGS_LOCK: + value = _processSettingForWrite(value) + ADDON.setSetting(key, value) + + +def _processSettingForWrite(value): + if isinstance(value, list): + value = binascii.hexlify(json.dumps(value)) + elif isinstance(value, bool): + value = value and 'true' or 'false' + return str(value) + + +def setGlobalProperty(key, val, base='script.plex.{0}'): + xbmcgui.Window(10000).setProperty(base.format(key), val) + + +def setGlobalBoolProperty(key, boolean, base='script.plex.{0}'): + xbmcgui.Window(10000).setProperty(base.format(key), boolean and '1' or '') + + +def getGlobalProperty(key): + return xbmc.getInfoLabel('Window(10000).Property(script.plex.{0})'.format(key)) + + +def showNotification(message, time_ms=3000, icon_path=None, header=ADDON.getAddonInfo('name')): + try: + icon_path = icon_path or translatePath(ADDON.getAddonInfo('icon')) + xbmc.executebuiltin('Notification({0},{1},{2},{3})'.format(header, message, time_ms, icon_path)) + except RuntimeError: # Happens when disabling the addon + LOG(message) + + +def videoIsPlaying(): + return xbmc.getCondVisibility('Player.HasVideo') + + +def messageDialog(heading='Message', msg=''): + from .windows import optionsdialog + optionsdialog.show(heading, msg, 'OK') + + +def showTextDialog(heading, text): + t = TextBox() + t.setControls(heading, text) + + +def sortTitle(title): + return title.startswith('The ') and title[4:] or title + + +def durationToText(seconds): + """ + Converts seconds to a short user friendly string + Example: 143 -> 2m 23s + """ + days = int(seconds / 86400000) + if days: + return '{0} day{1}'.format(days, days > 1 and 's' or '') + left = seconds % 86400000 + hours = int(left / 3600000) + if hours: + hours = '{0} hr{1} '.format(hours, hours > 1 and 's' or '') + else: + hours = '' + left = left % 3600000 + mins = int(left / 60000) + if mins: + return hours + '{0} min{1}'.format(mins, mins > 1 and 's' or '') + elif hours: + return hours.rstrip() + secs = int(left % 60000) + if secs: + secs /= 1000 + return '{0} sec{1}'.format(secs, secs > 1 and 's' or '') + return '0 seconds' + + +def durationToShortText(seconds): + """ + Converts seconds to a short user friendly string + Example: 143 -> 2m 23s + """ + days = int(seconds / 86400000) + if days: + return '{0} d'.format(days) + left = seconds % 86400000 + hours = int(left / 3600000) + if hours: + hours = '{0} h '.format(hours) + else: + hours = '' + left = left % 3600000 + mins = int(left / 60000) + if mins: + return hours + '{0} m'.format(mins) + elif hours: + return hours.rstrip() + secs = int(left % 60000) + if secs: + secs /= 1000 + return '{0} s'.format(secs) + return '0 s' + + +def cleanLeadingZeros(text): + if not text: + return '' + return re.sub('(?<= )0(\d)', r'\1', text) + + +def removeDups(dlist): + return [ii for n, ii in enumerate(dlist) if ii not in dlist[:n]] + + +SIZE_NAMES = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + + +def simpleSize(size): + """ + Converts bytes to a short user friendly string + Example: 12345 -> 12.06 KB + """ + s = 0 + if size > 0: + i = int(math.floor(math.log(size, 1024))) + p = math.pow(1024, i) + s = round(size / p, 2) + if (s > 0): + return '%s %s' % (s, SIZE_NAMES[i]) + else: + return '0B' + + +def timeDisplay(ms, cutHour=False): + h = ms / 3600000 + m = (ms % 3600000) / 60000 + s = (ms % 60000) / 1000 + if h >= 1 or not cutHour: + return '{0:0>2}:{1:0>2}:{2:0>2}'.format(int(h), int(m), int(s)) + return '{0:0>2}:{1:0>2}'.format(int(m), int(s)) + + +def simplifiedTimeDisplay(ms): + left, right = timeDisplay(ms).rsplit(':', 1) + left = left.lstrip('0:') or '0' + return left + ':' + right + + +def shortenText(text, size): + if len(text) < size: + return text + + return u'{0}\u2026'.format(text[:size - 1]) + + +def scaleResolution(w, h, by=advancedSettings.posterResolutionScale): + if 0 < by != 1.0: + px = w * h * by + wratio = h / float(w) + hratio = w / float(h) + return int(round((px / wratio) ** .5)), int(round((px / hratio) ** .5)) + return w, h + + +class TextBox: + # constants + WINDOW = 10147 + CONTROL_LABEL = 1 + CONTROL_TEXTBOX = 5 + + def __init__(self, *args, **kwargs): + # activate the text viewer window + xbmc.executebuiltin("ActivateWindow(%d)" % (self.WINDOW, )) + # get window + self.win = xbmcgui.Window(self.WINDOW) + # give window time to initialize + xbmc.sleep(1000) + + def setControls(self, heading, text): + # set heading + self.win.getControl(self.CONTROL_LABEL).setLabel(heading) + # set text + self.win.getControl(self.CONTROL_TEXTBOX).setText(text) + + +class SettingControl: + def __init__(self, setting, log_display, disable_value=''): + self.setting = setting + self.logDisplay = log_display + self.disableValue = disable_value + self._originalMode = None + self.store() + + def disable(self): + rpc.Settings.SetSettingValue(setting=self.setting, value=self.disableValue) + DEBUG_LOG('{0}: DISABLED'.format(self.logDisplay)) + + def set(self, value): + rpc.Settings.SetSettingValue(setting=self.setting, value=value) + DEBUG_LOG('{0}: SET={1}'.format(self.logDisplay, value)) + + def store(self): + try: + self._originalMode = rpc.Settings.GetSettingValue(setting=self.setting).get('value') + DEBUG_LOG('{0}: Mode stored ({1})'.format(self.logDisplay, self._originalMode)) + except: + ERROR() + + def restore(self): + if self._originalMode is None: + return + rpc.Settings.SetSettingValue(setting=self.setting, value=self._originalMode) + DEBUG_LOG('{0}: RESTORED'.format(self.logDisplay)) + + @contextlib.contextmanager + def suspend(self): + self.disable() + yield + self.restore() + + @contextlib.contextmanager + def save(self): + yield + self.restore() + + +def timeInDayLocalSeconds(): + now = datetime.datetime.now() + sod = datetime.datetime(year=now.year, month=now.month, day=now.day) + sod = int(time.mktime(sod.timetuple())) + return int(time.time() - sod) + + +def getKodiSkipSteps(): + try: + return rpc.Settings.GetSettingValue(setting="videoplayer.seeksteps")["value"] + except: + return + + +def getKodiSlideshowInterval(): + try: + return rpc.Settings.GetSettingValue(setting="slideshow.staytime")["value"] + except: + return 3 + + +kodiSkipSteps = getKodiSkipSteps() +slideshowInterval = getKodiSlideshowInterval() + + +CRON = None + + +class CronReceiver(): + def tick(self): + pass + + def halfHour(self): + pass + + def day(self): + pass + + +class Cron(threading.Thread): + def __init__(self, interval): + threading.Thread.__init__(self, name='CRON') + self.stopped = threading.Event() + self.force = threading.Event() + self.interval = interval + self._lastHalfHour = self._getHalfHour() + self._receivers = [] + + global CRON + + CRON = self + + def __enter__(self): + self.start() + DEBUG_LOG('Cron started') + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.stop() + self.join() + + def _wait(self): + ct = 0 + while ct < self.interval: + xbmc.sleep(100) + ct += 0.1 + if self.force.isSet(): + self.force.clear() + return True + if MONITOR.abortRequested() or self.stopped.isSet(): + return False + return True + + def forceTick(self): + self.force.set() + + def stop(self): + self.stopped.set() + + def run(self): + while self._wait(): + self._tick() + DEBUG_LOG('Cron stopped') + + def _getHalfHour(self): + tid = timeInDayLocalSeconds() / 60 + return tid - (tid % 30) + + def _tick(self): + receivers = list(self._receivers) + receivers = self._halfHour(receivers) + for r in receivers: + try: + r.tick() + except: + ERROR() + + def _halfHour(self, receivers): + hh = self._getHalfHour() + if hh == self._lastHalfHour: + return receivers + try: + receivers = self._day(receivers, hh) + ret = [] + for r in receivers: + try: + if not r.halfHour(): + ret.append(r) + except: + ret.append(r) + ERROR() + return ret + finally: + self._lastHalfHour = hh + + def _day(self, receivers, hh): + if hh >= self._lastHalfHour: + return receivers + ret = [] + for r in receivers: + try: + if not r.day(): + ret.append(r) + except: + ret.append(r) + ERROR() + return ret + + def registerReceiver(self, receiver): + if receiver not in self._receivers: + DEBUG_LOG('Cron: Receiver added: {0}'.format(receiver)) + self._receivers.append(receiver) + + def cancelReceiver(self, receiver): + if receiver in self._receivers: + DEBUG_LOG('Cron: Receiver canceled: {0}'.format(receiver)) + self._receivers.pop(self._receivers.index(receiver)) + + +def getTimeFormat(): + """ + Generic: + Use locale.timeformat setting to get and make use of the format. + + Possible values: + HH:mm:ss -> %H:%M:%S + regional -> legacy + H:mm:ss -> %-H:%M:%S + + Legacy: Not necessarily true for Omega?; regional spices things up (depending on Kodi version?) + Get global time format. + Kodi's time format handling is weird, as they return incompatible formats for strftime. + %H%H can be returned for manually set zero-padded values, in case of a regional zero-padded hour component, + only %H is returned. + + For now, sail around that by testing the current time for padded hour values. + + Tests of the values returned by xbmc.getRegion("time") as of Kodi Nexus (I believe): + %I:%M:%S %p = h:mm:ss, non-zero-padded, 12h PM + %I:%M:%S = 12h, h:mm:ss, non-zero-padded, regional + %I%I:%M:%S = 12h, zero padded, hh:mm:ss + %H%H:%M:%S = 24h, zero padded, hh:mm:ss + %H:%M:%S = 24h, zero padded, regional, regional (central europe) + + :return: tuple of strftime-compatible format, boolean padHour + """ + + fmt = None + nonPadHF = "%-H" if sys.platform != "win32" else "%#H" + nonPadIF = "%-I" if sys.platform != "win32" else "%#I" + + try: + fmt = rpc.Settings.GetSettingValue(setting="locale.timeformat")["value"] + except: + DEBUG_LOG("Couldn't get locale.timeformat setting, falling back to legacy detection") + + if fmt and fmt != "regional": + # HH = padded 24h + # hh = padded 12h + # H = unpadded 24h + # h = unpadded 12h + + # handle non-padded hour first + if fmt.startswith("H:") or fmt.startswith("h:"): + adjustedFmt = fmt.replace("H", nonPadHF).replace("h", nonPadIF) + else: + adjustedFmt = fmt.replace("HH", "%H").replace("hh", "%I") + + padHour = adjustedFmt.startswith("%H") or adjustedFmt.startswith("%I") + + else: + DEBUG_LOG("Regional time format detected, falling back to legacy detection of hour-padding") + # regional is weirdly always unpadded (unless the broken %H%H/%I%I notation is used + origFmt = xbmc.getRegion('time') + + adjustedFmt = origFmt.replace("%H%H", "%H").replace("%I%I", "%I") + + # Checking for %H%H or %I%I only would be the obvious way here to determine whether the hour should be padded, + # but the formats returned for regional settings with padding might only have %H in them. + # Use a fallback (unreliable). + currentTime = xbmc.getInfoLabel('System.Time') + padHour = "%H%H" in origFmt or "%I%I" in origFmt or (currentTime[0] == "0" and currentTime[1] != ":") + + # Kodi Omega on Android seems to have borked the regional format returned separately + # (not happening on Windows at least). Format returned can be "%H:mm:ss", which is incompatible with strftime; fix. + adjustedFmt = adjustedFmt.replace("mm", "%M").replace("ss", "%S").replace("xx", "%p") + adjustedFmtKN = adjustedFmt.replace("%M", "mm").replace("%H", "hh").replace("%I", "h").replace("%S", "ss").\ + replace("%p", "xx").replace(nonPadIF, "h").replace(nonPadHF, "h") + + return adjustedFmt, adjustedFmtKN, padHour + + +timeFormat, timeFormatKN, padHour = getTimeFormat() + + +def populateTimeFormat(): + global timeFormat, timeFormatKN, padHour + timeFormat, timeFormatKN, padHour = getTimeFormat() + + +def getPlatform(): + for key in [ + 'System.Platform.Android', + 'System.Platform.Linux.RaspberryPi', + 'System.Platform.Linux', + 'System.Platform.Windows', + 'System.Platform.OSX', + 'System.Platform.IOS', + 'System.Platform.Darwin', + 'System.Platform.ATV2' + ]: + if xbmc.getCondVisibility(key): + return key.rsplit('.', 1)[-1] + + +def getProgressImage(obj): + if not obj.get('viewOffset') or not obj.get('duration'): + return '' + pct = int((obj.viewOffset.asInt() / obj.duration.asFloat()) * 100) + pct = pct - pct % 2 # Round to even number - we have even numbered progress only + pct = max(pct, 2) + return 'script.plex/progress/{0}.png'.format(pct) + + +def backgroundFromArt(art, width=1920, height=1080, background=colors.noAlpha.Background): + if not art: + return + return art.asTranscodedImageURL( + width, height, + blur=advancedSettings.backgroundArtBlurAmount2, + opacity=advancedSettings.backgroundArtOpacityAmount2, + background=background + ) + + +def trackIsPlaying(track): + return xbmc.getCondVisibility('String.StartsWith(MusicPlayer.Comment,{0})'.format('PLEX-{0}:'.format(track.ratingKey))) + + +def addURLParams(url, params): + if '?' in url: + url += '&' + else: + url += '?' + url += six.moves.urllib.parse.urlencode(params) + return url + + +def garbageCollect(): + gc.collect(2) + + +def shutdown(): + global MONITOR, ADDON, T, _SHUTDOWN + _SHUTDOWN = True + del MONITOR + del T + del ADDON diff --git a/script.plexmod/lib/windows/__init__.py b/script.plexmod/lib/windows/__init__.py new file mode 100644 index 000000000..f96b69008 --- /dev/null +++ b/script.plexmod/lib/windows/__init__.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import +from . import kodigui +from lib import util + +kodigui.MONITOR = util.MONITOR diff --git a/script.plexmod/lib/windows/background.py b/script.plexmod/lib/windows/background.py new file mode 100644 index 000000000..8940d1c6b --- /dev/null +++ b/script.plexmod/lib/windows/background.py @@ -0,0 +1,43 @@ +from __future__ import absolute_import +from . import kodigui +from lib import util + +util.setGlobalProperty('background.busy', '') +util.setGlobalProperty('background.shutdown', '') +util.setGlobalProperty('background.splash', '') + + +class BackgroundWindow(kodigui.BaseWindow): + xmlFile = 'script-plex-background.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + def __init__(self, *args, **kwargs): + kodigui.BaseWindow.__init__(self, *args, **kwargs) + self.function = kwargs.get('function') + + def onFirstInit(self): + self.function() + self.doClose() + + def onAction(self, action): + pass + + +def setBusy(on=True): + util.setGlobalProperty('background.busy', on and '1' or '') + + +def setSplash(on=True): + util.setGlobalProperty('background.splash', on and '1' or '') + + +def setShutdown(on=True): + util.setGlobalProperty('background.shutdown', on and '1' or '') + + +def killMonitor(): + kodigui.MONITOR = None diff --git a/script.plexmod/lib/windows/busy.py b/script.plexmod/lib/windows/busy.py new file mode 100644 index 000000000..1f128c5ec --- /dev/null +++ b/script.plexmod/lib/windows/busy.py @@ -0,0 +1,142 @@ +from __future__ import absolute_import +from . import kodigui +from lib import util +from kodi_six import xbmcgui +import threading + + +class BusyWindow(kodigui.BaseDialog): + xmlFile = 'script-plex-busy.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + +class BusyClosableWindow(BusyWindow): + ctx = None + + def onAction(self, action): + if action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_STOP): + self.ctx.shouldClose = True + + +class BusyClosableMsgWindow(BusyClosableWindow): + xmlFile = 'script-plex-busy_msg.xml' + + def setMessage(self, msg): + self.setProperty("message", msg) + + +def dialog(msg='LOADING', condition=None, delay=True): + def methodWrap(func): + def inner(*args, **kwargs): + timer = None + w = BusyWindow.create(show=not delay) + + if delay: + timer = threading.Timer(0.5, w.show) + timer.start() + + try: + return func(*args, **kwargs) + finally: + if timer and timer.is_alive(): + timer.cancel() + timer.join() + del timer + w.doClose() + try: + del w + except: + pass + util.garbageCollect() + + if condition is not None: + return condition() and inner or func + return inner + + return methodWrap + + +def widthDialog(method, msg, *args, **kwargs): + return dialog(msg or 'LOADING')(method)(*args, **kwargs) + + +class BusyContext(object): + w = None + timer = None + shouldClose = False + window_cls = BusyWindow + delay = False + + def __enter__(self): + self.w = self.window_cls.create(show=not self.delay) + if self.delay: + self.timer = threading.Timer(0.5, lambda: self.w.show()) + self.w.ctx = self + return self + + def __exit__(self, exc_type, exc_value, tb): + if exc_type is not None: + util.ERROR() + + if self.timer and self.timer.is_alive(): + self.timer.cancel() + self.timer.join() + + self.w.doClose() + del self.w + self.w = None + util.garbageCollect() + return True + + +class BusyMsgContext(BusyContext): + window_cls = BusyClosableMsgWindow + + def setMessage(self, msg): + self.w.setMessage(msg) + + +class BusySignalContext(BusyMsgContext): + """ + Duplicates functionality of plex.CallbackEvent to a certain degree + """ + window_cls = BusyWindow + delay = True + + def __init__(self, context, signal, wait_max=10, delay=True): + self.wfSignal = signal + self.signalEmitter = context + self.waitMax = wait_max + self.ignoreSignal = False + self.signalReceived = False + self.delay = delay + + super(BusySignalContext, self).__init__() + + context.on(signal, self.onSignal) + + def onSignal(self, *args, **kwargs): + self.signalReceived = True + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_type is not None: + util.ERROR() + + try: + if not self.ignoreSignal: + waited = 0 + while not self.signalReceived and waited < self.waitMax: + util.MONITOR.waitForAbort(0.1) + waited += 0.1 + finally: + self.signalEmitter.off(self.wfSignal, self.onSignal) + + return super(BusySignalContext, self).__exit__(exc_type, exc_val, exc_tb) + + +class BusyClosableMsgContext(BusyMsgContext): + pass diff --git a/script.plexmod/lib/windows/currentplaylist.py b/script.plexmod/lib/windows/currentplaylist.py new file mode 100644 index 000000000..91dffd23a --- /dev/null +++ b/script.plexmod/lib/windows/currentplaylist.py @@ -0,0 +1,337 @@ +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui + +from . import busy +from . import windowutils +from . import dropdown +from . import opener + +from lib import util +from lib import player +from lib import kodijsonrpc + +from lib.util import T + + +class CurrentPlaylistWindow(kodigui.ControlledWindow, windowutils.UtilMixin): + xmlFile = 'script-plex-music_current_playlist.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + LI_THUMB_DIM = (64, 64) + ALBUM_THUMB_DIM = util.scaleResolution(639, 639) + + PLAYLIST_LIST_ID = 101 + + SEEK_BUTTON_ID = 500 + SEEK_IMAGE_ID = 510 + + POSITION_IMAGE_ID = 201 + SELECTION_INDICATOR = 202 + SELECTION_BOX = 203 + + REPEAT_BUTTON_ID = 401 + SHUFFLE_BUTTON_ID = 402 + SHUFFLE_REMOTE_BUTTON_ID = 422 + SKIP_PREV_BUTTON_ID = 404 + SKIP_NEXT_BUTTON_ID = 409 + PLAYLIST_BUTTON_ID = 410 + OPTIONS_BUTTON_ID = 411 + STOP_BUTTON_ID = 407 + + SEEK_IMAGE_WIDTH = 819 + SELECTION_BOX_WIDTH = 101 + SELECTION_INDICATOR_Y = 896 + + BAR_X = 0 + BAR_Y = 921 + BAR_RIGHT = 819 + BAR_BOTTOM = 969 + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + self.selectedOffset = 0 + self.setDuration() + self.exitCommand = None + self.musicPlayerWinID = kwargs.get('winID') + + def doClose(self, **kwargs): + player.PLAYER.off('playback.started', self.onPlayBackStarted) + player.PLAYER.off('playlist.changed', self.playQueueCallback) + if player.PLAYER.handler.playQueue and player.PLAYER.handler.playQueue.isRemote: + player.PLAYER.handler.playQueue.off('change', self.updateProperties) + kodigui.ControlledWindow.doClose(self) + + def onFirstInit(self): + self.playlistListControl = kodigui.ManagedControlList(self, self.PLAYLIST_LIST_ID, 9) + self.setupSeekbar() + + self.fillPlaylist() + self.selectPlayingItem() + self.setFocusId(self.PLAYLIST_LIST_ID) + + self.updateProperties() + if player.PLAYER.handler.playQueue and player.PLAYER.handler.playQueue.isRemote: + player.PLAYER.handler.playQueue.on('change', self.updateProperties) + player.PLAYER.on('playlist.changed', self.playQueueCallback) + + def onAction(self, action): + try: + controlID = self.getFocusId() + if self.checkSeekActions(action, controlID): + return + except: + util.ERROR() + + kodigui.ControlledWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.PLAYLIST_LIST_ID: + self.playlistListClicked() + elif controlID == self.SEEK_BUTTON_ID: + self.seekButtonClicked() + elif controlID == self.SHUFFLE_BUTTON_ID: + self.fillPlaylist() + elif controlID == self.SHUFFLE_REMOTE_BUTTON_ID: + player.PLAYER.handler.playQueue.setShuffle() + elif controlID == self.REPEAT_BUTTON_ID: + self.repeatButtonClicked() + elif controlID == self.SKIP_PREV_BUTTON_ID: + self.skipPrevButtonClicked() + elif controlID == self.SKIP_NEXT_BUTTON_ID: + self.skipNextButtonClicked() + elif controlID == self.OPTIONS_BUTTON_ID: + self.optionsButtonClicked() + elif controlID == self.STOP_BUTTON_ID: + self.stopButtonClicked() + + def onFocus(self, controlID): + if controlID == self.SEEK_BUTTON_ID: + try: + if player.PLAYER.isPlaying(): + self.selectedOffset = player.PLAYER.getTime() * 1000 + else: + self.selectedOffset = 0 + except RuntimeError: + self.selectedOffset = 0 + + self.updateSelectedProgress() + + def onPlayBackStarted(self, **kwargs): + xbmc.sleep(2000) + self.setDuration() + + def repeatButtonClicked(self): + if player.PLAYER.handler.playQueue and player.PLAYER.handler.playQueue.isRemote: + if xbmc.getCondVisibility('Playlist.IsRepeatOne'): + xbmc.executebuiltin('PlayerControl(RepeatOff)') + elif player.PLAYER.handler.playQueue.isRepeat: + player.PLAYER.handler.playQueue.setRepeat(False) + player.PLAYER.handler.playQueue.refresh(force=True) + xbmc.executebuiltin('PlayerControl(RepeatOne)') + else: + player.PLAYER.handler.playQueue.setRepeat(True) + player.PLAYER.handler.playQueue.refresh(force=True) + else: + xbmc.executebuiltin('PlayerControl(Repeat)') + + def skipPrevButtonClicked(self): + if not xbmc.getCondVisibility('MusicPlayer.HasPrevious') and player.PLAYER.handler.playQueue and player.PLAYER.handler.playQueue.isRemote: + util.DEBUG_LOG('MusicPlayer: No previous in Kodi playlist - refreshing remote PQ') + if not player.PLAYER.handler.playQueue.refresh(force=True, wait=True): + return + + xbmc.executebuiltin('PlayerControl(Previous)') + + def skipNextButtonClicked(self): + if not xbmc.getCondVisibility('MusicPlayer.HasNext') and player.PLAYER.handler.playQueue and player.PLAYER.handler.playQueue.isRemote: + util.DEBUG_LOG('MusicPlayer: No next in Kodi playlist - refreshing remote PQ') + if not player.PLAYER.handler.playQueue.refresh(force=True, wait=True): + return + + xbmc.executebuiltin('PlayerControl(Next)') + + def optionsButtonClicked(self, pos=(670, 1060)): + track = player.PLAYER.currentTrack() + if not track: + return + + options = [] + + options.append({'key': 'to_album', 'display': T(32300, 'Go to Album')}) + options.append({'key': 'to_artist', 'display': T(32301, 'Go to Artist')}) + options.append({'key': 'to_section', 'display': T(32302, u'Go to {0}').format(track.getLibrarySectionTitle())}) + + choice = dropdown.showDropdown(options, pos, close_direction='down', pos_is_bottom=True, close_on_playback_ended=True) + if not choice: + return + + if choice['key'] == 'to_album': + self.processCommand(opener.open(track.parentRatingKey)) + elif choice['key'] == 'to_artist': + self.processCommand(opener.open(track.grandparentRatingKey)) + elif choice['key'] == 'to_section': + self.goHome(track.getLibrarySectionId()) + + def stopButtonClicked(self): + xbmc.executebuiltin('Action(Back, {})'.format(self.musicPlayerWinID)) + xbmc.sleep(500) + self.doClose() + + def selectPlayingItem(self): + for mli in self.playlistListControl: + if xbmc.getCondVisibility('String.StartsWith(MusicPlayer.Comment,{0})'.format(mli.dataSource['comment'].split(':', 1)[0])): + self.playlistListControl.selectItem(mli.pos()) + break + + def playQueueCallback(self, **kwargs): + self.setProperty('pq.isshuffled', player.PLAYER.handler.playQueue.isShuffled and '1' or '') + mli = self.playlistListControl.getSelectedItem() + pi = mli.dataSource + plexID = pi['comment'].split(':', 1)[0] + viewPos = self.playlistListControl.getViewPosition() + + self.fillPlaylist() + + for ni in self.playlistListControl: + if ni.dataSource['comment'].split(':', 1)[0] == plexID: + self.playlistListControl.selectItem(ni.pos()) + break + + xbmc.sleep(100) + + newViewPos = self.playlistListControl.getViewPosition() + if viewPos != newViewPos: + diff = newViewPos - viewPos + self.playlistListControl.shiftView(diff, True) + + def seekButtonClicked(self): + player.PLAYER.seekTime(self.selectedOffset / 1000.0) + + def playlistListClicked(self): + mli = self.playlistListControl.getSelectedItem() + if not mli: + return + player.PLAYER.playselected(mli.pos()) + + def createListItem(self, pi, idx): + label2 = '{0} / {1}'.format(pi['artist'][0], pi['album']) + plexInfo = pi['comment'] + mli = kodigui.ManagedListItem(pi['title'], label2, thumbnailImage=pi['thumbnail'], data_source=pi) + mli.setProperty('track.duration', util.simplifiedTimeDisplay(pi['duration'] * 1000)) + if plexInfo.startswith('PLEX-'): + mli.setProperty('track.ID', plexInfo.split('-', 1)[-1].split(':', 1)[0]) + mli.setProperty('track.number', str(pi['playcount'])) + else: + mli.setProperty('track.ID', '!NONE!') + mli.setProperty('track.number', str(pi['track'])) + mli.setProperty('playlist.position', str(idx)) + + mli.setProperty('file', pi['file']) + return mli + + @busy.dialog() + def fillPlaylist(self): + items = [] + idx = 1 + for pi in kodijsonrpc.rpc.PlayList.GetItems( + playlistid=xbmc.PLAYLIST_MUSIC, properties=['title', 'artist', 'album', 'track', 'thumbnail', 'duration', 'playcount', 'comment', 'file'] + )['items']: + mli = self.createListItem(pi, idx) + if mli: + mli.setProperty('index', str(idx)) + items.append(mli) + idx += 1 + + self.playlistListControl.reset() + self.playlistListControl.addItems(items) + + def setupSeekbar(self): + self.seekbarControl = self.getControl(self.SEEK_IMAGE_ID) + self.selectionIndicator = self.getControl(self.SELECTION_INDICATOR) + self.selectionBox = self.getControl(self.SELECTION_BOX) + self.selectionBoxHalf = self.SELECTION_BOX_WIDTH // 2 + self.selectionBoxMax = self.SEEK_IMAGE_WIDTH + player.PLAYER.on('playback.started', self.onPlayBackStarted) + + def checkSeekActions(self, action, controlID): + if controlID == self.SEEK_BUTTON_ID: + if action == xbmcgui.ACTION_MOUSE_MOVE: + self.seekMouse(action) + return True + elif action in (xbmcgui.ACTION_MOVE_RIGHT, xbmcgui.ACTION_NEXT_ITEM): + self.seekForward(3000) + return True + elif action in (xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_PREV_ITEM): + self.seekBack(3000) + return True + # elif action == xbmcgui.ACTION_MOVE_UP: + # self.seekForward(60000) + # elif action == xbmcgui.ACTION_MOVE_DOWN: + # self.seekBack(60000) + elif action == xbmcgui.ACTION_STOP: + self.stopButtonClicked() + return True + + def setDuration(self): + try: + self.duration = player.PLAYER.getTotalTime() * 1000 + except RuntimeError: # Not playing + self.duration = 0 + + def seekForward(self, offset): + self.selectedOffset += offset + if self.selectedOffset > self.duration: + self.selectedOffset = self.duration + + self.updateSelectedProgress() + + def seekBack(self, offset): + self.selectedOffset -= offset + if self.selectedOffset < 0: + self.selectedOffset = 0 + + self.updateSelectedProgress() + + def seekMouse(self, action): + x = self.mouseXTrans(action.getAmount1()) + y = self.mouseYTrans(action.getAmount2()) + if not (self.BAR_Y <= y <= self.BAR_BOTTOM): + return + + if not (self.BAR_X <= x <= self.BAR_RIGHT): + return + + self.selectedOffset = int((x - self.BAR_X) / float(self.SEEK_IMAGE_WIDTH) * self.duration) + self.updateSelectedProgress() + + def updateSelectedProgress(self): + ratio = self.selectedOffset / float(self.duration) + w = int(ratio * self.SEEK_IMAGE_WIDTH) + self.seekbarControl.setWidth(w or 1) + + self.selectionIndicator.setPosition(w, self.SELECTION_INDICATOR_Y) + if w < self.selectionBoxHalf - 3: + self.selectionBox.setPosition((-self.selectionBoxHalf + (self.selectionBoxHalf - w)) - 3, 0) + elif w > self.selectionBoxMax: + self.selectionBox.setPosition((-self.SELECTION_BOX_WIDTH + (self.SEEK_IMAGE_WIDTH - w)) + 3, 0) + else: + self.selectionBox.setPosition(-self.selectionBoxHalf, 0) + self.setProperty('time.selection', util.simplifiedTimeDisplay(int(self.selectedOffset))) + + def updateProperties(self, **kwargs): + pq = player.PLAYER.handler.playQueue + if pq: + if pq.isRemote: + self.setProperty('pq.isRemote', '1') + self.setProperty('pq.hasnext', pq.allowSkipNext and '1' or '') + self.setProperty('pq.hasprev', pq.allowSkipPrev and '1' or '') + self.setProperty('pq.repeat', pq.isRepeat and '1' or '') + self.setProperty('pq.shuffled', pq.isShuffled and '1' or '') + else: + self.setProperties(('pq.isRemote', 'pq.hasnext', 'pq.hasprev', 'pq.repeat', 'pq.shuffled'), '') diff --git a/script.plexmod/lib/windows/dropdown.py b/script.plexmod/lib/windows/dropdown.py new file mode 100644 index 000000000..dc973b02b --- /dev/null +++ b/script.plexmod/lib/windows/dropdown.py @@ -0,0 +1,247 @@ +from __future__ import absolute_import +from kodi_six import xbmc, xbmcgui +from . import kodigui + +from lib import util + +SEPARATOR = None + + +class DropdownDialog(kodigui.BaseDialog): + xmlFile = 'script-plex-dropdown.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + GROUP_ID = 100 + OPTIONS_LIST_ID = 250 + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + self.options = kwargs.get('options') + self.pos = kwargs.get('pos') + self.lastSelectedItem = None + self.optionsList = None + self.roundRobin = kwargs.get('round_robin', True) + self.posIsBottom = kwargs.get('pos_is_bottom') + self.closeDirection = kwargs.get('close_direction') + self.setDropdownProp = kwargs.get('set_dropdown_prop', False) + self.withIndicator = kwargs.get('with_indicator', False) + self.suboptionCallback = kwargs.get('suboption_callback') + self.closeOnPlaybackEnded = kwargs.get('close_on_playback_ended', False) + self.closeOnlyWithBack = kwargs.get('close_only_with_back', False) + self.alignItems = kwargs.get('align_items', 'center') + self.optionsCallback = kwargs.get('options_callback', None) + self.header = kwargs.get('header') + self.selectIndex = kwargs.get('select_index') + self.onCloseCallback = kwargs.get('onclose_callback') + self.choice = None + + @property + def x(self): + return min(self.width - 360, self.pos[0]) + + @property + def y(self): + y = self.pos[1] + if self.posIsBottom: + y -= (len(self.options) * 66) + 80 + return y + + def onFirstInit(self): + self.setProperty('dropdown', self.setDropdownProp and '1' or '') + self.setProperty('header', self.header) + self.optionsList = kodigui.ManagedControlList(self, self.OPTIONS_LIST_ID, 8) + self.showOptions() + height = min(66 * 14, (len(self.options) * 66)) + 80 + self.getControl(100).setPosition(self.x, self.y) + + shadowControl = self.getControl(110) + if self.header: + shadowControl.setHeight(height + 86) + self.getControl(111).setHeight(height + 6) + else: + shadowControl.setHeight(height) + + self.setProperty('show', '1') + self.setProperty('close.direction', self.closeDirection) + if self.closeOnPlaybackEnded: + from lib import player + player.PLAYER.on('session.ended', self.playbackSessionEnded) + + def onAction(self, action): + try: + pass + except: + util.ERROR() + + if self.roundRobin and action in (xbmcgui.ACTION_MOVE_UP, xbmcgui.ACTION_MOVE_DOWN) and \ + self.getFocusId() == self.OPTIONS_LIST_ID: + to_pos = None + last_index = self.optionsList.size() - 1 + + if last_index > 0: + if action == xbmcgui.ACTION_MOVE_UP and self.lastSelectedItem in (0, None) \ + and self.optionsList.topHasFocus(): + to_pos = last_index + + elif action == xbmcgui.ACTION_MOVE_DOWN and self.lastSelectedItem == last_index \ + and self.optionsList.bottomHasFocus(): + to_pos = 0 + + if to_pos is not None: + self.optionsList.setSelectedItemByPos(to_pos) + self.lastSelectedItem = to_pos + return + + self.lastSelectedItem = self.optionsList.control.getSelectedPosition() + + kodigui.BaseDialog.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.OPTIONS_LIST_ID: + self.setChoice() + else: + self.doClose() + + def playbackSessionEnded(self, **kwargs): + self.doClose() + + def doClose(self): + if self.closeOnPlaybackEnded: + from lib import player + player.PLAYER.off('session.ended', self.playbackSessionEnded) + + self.optionsList.reset() + self.optionsList = None + + self.setProperty('show', '') + + super(DropdownDialog, self).doClose() + + def onClosed(self): + if self.onCloseCallback: + self.onCloseCallback(self.choice) + + def setChoice(self): + mli = self.optionsList.getSelectedItem() + if not mli: + return + + choice = self.options[self.optionsList.getSelectedPosition()] + + if choice.get('ignore'): + return + + if self.suboptionCallback: + options = self.suboptionCallback(choice) + if options: + sub = showDropdown(options, (self.x + 290, self.y + 10), close_direction='left', with_indicator=True) + if not sub: + return + + choice['sub'] = sub + + self.choice = choice + if self.optionsCallback: + self.optionsCallback(self.optionsList, mli) + + del mli + + if not self.closeOnlyWithBack: + self.doClose() + + def showOptions(self): + items = [] + options = [] + for oo in self.options: + if oo: + o = oo.copy() + item = kodigui.ManagedListItem(o['display'], thumbnailImage=o.get('indicator', ''), data_source=o) + item.setProperty('with.indicator', self.withIndicator and '1' or '') + item.setProperty('align', self.alignItems) + items.append(item) + options.append(o) + else: + if items: + items[-1].setProperty('separator', '1') + + self.options = options + + if len(items) > 1: + items[0].setProperty('first', '1') + items[-1].setProperty('last', '1') + elif items: + items[0].setProperty('only', '1') + + self.optionsList.reset() + self.optionsList.addItems(items) + + self.setFocusId(self.OPTIONS_LIST_ID) + + if self.selectIndex is not None: + self.optionsList.setSelectedItemByPos(self.selectIndex) + self.lastSelectedItem = self.selectIndex + + +class DropdownHeaderDialog(DropdownDialog): + xmlFile = 'script-plex-dropdown_header.xml' + + +def showDropdown( + options, pos=None, + pos_is_bottom=False, + close_direction='top', + set_dropdown_prop=True, + with_indicator=False, + suboption_callback=None, + close_on_playback_ended=False, + close_only_with_back=False, + align_items='center', + options_callback=None, + header=None, + select_index=None, + onclose_callback=None, +): + + if header: + pos = pos or (660, 400) + w = DropdownHeaderDialog.open( + options=options, pos=pos, + pos_is_bottom=pos_is_bottom, + close_direction=close_direction, + set_dropdown_prop=set_dropdown_prop, + with_indicator=with_indicator, + suboption_callback=suboption_callback, + close_on_playback_ended=close_on_playback_ended, + close_only_with_back=close_only_with_back, + align_items=align_items, + options_callback=options_callback, + header=header, + select_index=select_index, + onclose_callback=onclose_callback, + ) + else: + pos = pos or (810, 400) + w = DropdownDialog.open( + options=options, pos=pos, + pos_is_bottom=pos_is_bottom, + close_direction=close_direction, + set_dropdown_prop=set_dropdown_prop, + with_indicator=with_indicator, + suboption_callback=suboption_callback, + close_on_playback_ended=close_on_playback_ended, + close_only_with_back=close_only_with_back, + align_items=align_items, + options_callback=options_callback, + header=header, + select_index=select_index, + onclose_callback=onclose_callback, + ) + choice = w.choice + w = None + del w + util.garbageCollect() + return choice diff --git a/script.plexmod/lib/windows/episodes.py b/script.plexmod/lib/windows/episodes.py new file mode 100644 index 000000000..65af08754 --- /dev/null +++ b/script.plexmod/lib/windows/episodes.py @@ -0,0 +1,1202 @@ +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui + +from lib import util +from lib import backgroundthread +from lib import metadata +from lib import player + +from plexnet import plexapp, playlist, plexplayer +from plexnet.util import INTERFACE + +from . import busy +from . import videoplayer +from . import dropdown +from . import windowutils +from . import opener +from . import search +from . import playersettings +from . import info +from . import optionsdialog +from . import preplayutils +from . import pagination +from . import playbacksettings + +from lib.util import T +from .mixins import SeasonsMixin + +VIDEO_RELOAD_KW = dict(includeExtras=1, includeExtrasCount=10, includeChapters=1) + + +class EpisodeReloadTask(backgroundthread.Task): + def setup(self, episode, callback, with_progress=False): + self.episode = episode + self.callback = callback + self.withProgress = with_progress + return self + + def run(self): + if self.isCanceled(): + return + + if not plexapp.SERVERMANAGER.selectedServer: + # Could happen during sign-out for instance + return + + try: + self.episode.reload(checkFiles=1, includeChapters=1, fromMediaChoice=self.episode.mediaChoice is not None) + if self.isCanceled(): + return + self.callback(self, self.episode, with_progress=self.withProgress) + except: + util.ERROR() + + +class EpisodesPaginator(pagination.MCLPaginator): + thumbFallback = 'script.plex/thumb_fallbacks/show.png' + _currentEpisode = None + + def reset(self): + super(EpisodesPaginator, self).reset() + self._currentEpisode = None + + def getData(self, offset, amount): + return (self.parentWindow.season or self.parentWindow.show_).episodes(offset=offset, limit=amount) + + def createListItem(self, data): + mli = super(EpisodesPaginator, self).createListItem(data) + self.parentWindow.setItemInfo(data, mli) + return mli + + def prepareListItem(self, data, mli): + if not mli.dataSource.isWatched: + mli.setProperty('unwatched.count', str(mli.dataSource.unViewedLeafCount)) + mli.setProperty('progress', util.getProgressImage(mli.dataSource)) + + def setEpisode(self, ep): + self._currentEpisode = ep + + @property + def initialPage(self): + episode = self.parentWindow.episode + offset = 0 + amount = self.initialPageSize + if episode: + self.setEpisode(episode) + # try cutting the query short while not querying all episodes, to find the slice with the currently + # selected episode in it + episodes = [] + _amount = self.initialPageSize + self.orphans + epSeasonIndex = int(episode.index or 1) - 1 # .index is 1-based + if _amount < self.leafCount: + _amount = self.initialPageSize * 2 + notFound = False + while episode not in episodes: + offset = int(max(0, epSeasonIndex - _amount / 2)) + episodes = self.getData(offset, int(_amount)) + + if _amount >= self.leafCount: + # ep not found? + notFound = True + break + + # in case the episode wasn't found inside the slice, increase the slice's size + _amount *= 2 + + if notFound: + # search conservatively + util.DEBUG_LOG("Episode not found with intelligent index-based search, re-trying conservatively") + _amount = self.initialPageSize * 2 + offset = 0 + episodes = self.getData(offset, int(_amount)) + while episode not in episodes: + offset = _amount + episodes = self.getData(offset, int(_amount)) + + if _amount >= self.leafCount: + break + + _amount *= 2 + else: + # shortcut for short seasons + episodes = self.getData(offset, int(_amount)) + + else: + return super(EpisodesPaginator, self).initialPage + + episodeFound = episode and episode in episodes + if episodeFound: + if self.initialPageSize + self.orphans < self.leafCount: + # slice around the episode + # Clamp the left side dynamically based on the item index and how many items are left in the season. + # The episodes list might be longer than our limit, because the season doesn't necessarily have all the + # episodes in it and we're basing the initial load on the current episode's index, which is the actual + # index of the episode in the season, not what's physically there. To find the episode, we're + # dynamically increasing the window size above. Re-clamp to :amount:, adding slack to both sides if + # the remaining episodes would fit inside half of :amount:. + tmpEpIdx = episodes.index(episode) + leftBoundary = self.initialPageSize - len(episodes[tmpEpIdx:tmpEpIdx + self.orphans]) + + left = max(tmpEpIdx - leftBoundary, 0) + offset += left + epsLeft = self.leafCount - offset + # avoid short pages on the right end + if epsLeft <= self.initialPageSize + self.orphans: + amount = epsLeft + + # avoid short pages on the left end + if offset < self.orphans and amount + offset < self.initialPageSize + self.orphans: + amount += offset + left = 0 + offset = 0 + + episodes = episodes[left:left + amount] + + self.offset = offset + self._currentAmount = len(episodes) + + return episodes + + def selectItem(self, amount, more_left=False, more_right=False, items=None): + if not super(EpisodesPaginator, self).selectItem(amount, more_left): + if (self._currentEpisode and items) and self._currentEpisode in items: + self.control.selectItem(items.index(self._currentEpisode) + (1 if more_left else 0)) + + +class RelatedPaginator(pagination.BaseRelatedPaginator): + def getData(self, offset, amount): + return self.parentWindow.show_.getRelated(offset=offset, limit=amount) + + +class EpisodesWindow(kodigui.ControlledWindow, windowutils.UtilMixin, SeasonsMixin, + playbacksettings.PlaybackSettingsMixin): + xmlFile = 'script-plex-episodes.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + THUMB_AR16X9_DIM = util.scaleResolution(657, 393) + POSTER_DIM = util.scaleResolution(420, 630) + RELATED_DIM = util.scaleResolution(268, 397) + EXTRA_DIM = util.scaleResolution(329, 185) + ROLES_DIM = util.scaleResolution(334, 334) + + LIST_OPTIONS_BUTTON_ID = 111 + + EPISODE_LIST_ID = 400 + SEASONS_LIST_ID = 401 + EXTRA_LIST_ID = 402 + RELATED_LIST_ID = 403 + ROLES_LIST_ID = 404 + + OPTIONS_GROUP_ID = 200 + + HOME_BUTTON_ID = 201 + SEARCH_BUTTON_ID = 202 + PLAYER_STATUS_BUTTON_ID = 204 + + PROGRESS_IMAGE_ID = 250 + + PLAY_BUTTON_ID = 301 + SHUFFLE_BUTTON_ID = 302 + OPTIONS_BUTTON_ID = 303 + INFO_BUTTON_ID = 304 + SETTINGS_BUTTON_ID = 305 + MEDIA_BUTTON_ID = 307 + + SEASONS_CONTROL_ATTR = "seasonsListControl" + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + windowutils.UtilMixin.__init__(self) + self.episode = None + self.reset(kwargs.get('episode'), kwargs.get('season'), kwargs.get('show')) + self.initialEpisode = kwargs.get('episode') + self.parentList = kwargs.get('parentList') + self.lastItem = None + self.lastFocusID = None + self.lastNonOptionsFocusID = None + self.episodesPaginator = None + self.relatedPaginator = None + self.cameFrom = kwargs.get('came_from') + self.tasks = backgroundthread.Tasks() + self.initialized = False + self.closing = False + self._reloadVideos = [] + + def reset(self, episode, season=None, show=None): + self.episode = episode + self.season = season if season is not None else self.episode.season() + try: + self.show_ = show or (self.episode or self.season).show().reload(includeExtras=1, includeExtrasCount=10) + except IndexError: + raise util.NoDataException + + self.parentList = None + self.seasons = None + self._reloadVideos = [] + #self.initialized = False + + def doClose(self): + self.closing = True + self.episodesPaginator = None + self.relatedPaginator = None + kodigui.ControlledWindow.doClose(self) + if self.tasks: + self.tasks.cancel() + self.tasks = None + try: + player.PLAYER.off('new.video', self.onNewVideo) + except KeyError: + pass + + @busy.dialog() + def _onFirstInit(self): + self.episodeListControl = kodigui.ManagedControlList(self, self.EPISODE_LIST_ID, 5) + self.progressImageControl = self.getControl(self.PROGRESS_IMAGE_ID) + + self.extraListControl = kodigui.ManagedControlList(self, self.EXTRA_LIST_ID, 5) + self.relatedListControl = kodigui.ManagedControlList(self, self.RELATED_LIST_ID, 5) + self.rolesListControl = kodigui.ManagedControlList(self, self.ROLES_LIST_ID, 5) + self.seasonsListControl = kodigui.ManagedControlList(self, self.SEASONS_LIST_ID, 5) + + self._setup() + self.postSetup() + + def doAutoPlay(self): + # First reload the video to get all the other info + self.initialEpisode.reload(checkFiles=1, **VIDEO_RELOAD_KW) + return self.playButtonClicked(force_episode=self.initialEpisode) + + def onFirstInit(self): + self._onFirstInit() + + if self.show_ and self.show_.theme and not util.getSetting("slow_connection", False) and \ + (not self.cameFrom or self.cameFrom != self.show_.ratingKey): + volume = self.show_.settings.getThemeMusicValue() + if volume > 0: + player.PLAYER.playBackgroundMusic(self.show_.theme.asURL(True), volume, + self.show_.ratingKey) + + @busy.dialog() + def onReInit(self): + if not self.tasks: + self.tasks = backgroundthread.Tasks() + + try: + self.selectEpisode() + except AttributeError: + raise util.NoDataException + + mli = self.episodeListControl.getSelectedItem() + if not mli or not self.episodesPaginator: + return + + reloadItems = [mli] + for v in self._reloadVideos: + for m in self.episodeListControl: + if m.dataSource == v: + reloadItems.append(m) + self.episodesPaginator.prepareListItem(v, m) + + # re-set current item's progress to a loading state + if util.getSetting("slow_connection", False): + self.progressImageControl.setWidth(1) + mli.setProperty('remainingTime', T(32914, "Loading")) + + self.reloadItems(items=reloadItems, with_progress=True) + self.episodesPaginator.setEpisode(self._reloadVideos and self._reloadVideos[-1] or mli) + self._reloadVideos = [] + self.fillRelated() + + def postSetup(self, from_select_episode=False): + self.selectEpisode(from_select_episode=from_select_episode) + self.checkForHeaderFocus(xbmcgui.ACTION_MOVE_DOWN) + self.setFocusId(self.PLAY_BUTTON_ID) + self.initialized = True + + @busy.dialog() + def setup(self): + self._setup() + + def _setup(self, from_select_episode=False): + player.PLAYER.on('new.video', self.onNewVideo) + (self.season or self.show_).reload(checkFiles=1, **VIDEO_RELOAD_KW) + + if not from_select_episode or not self.episodesPaginator: + self.episodesPaginator = EpisodesPaginator(self.episodeListControl, + leaf_count=int(self.season.leafCount) if self.season else 0, + parent_window=self) + + if not from_select_episode or not self.episodesPaginator: + self.relatedPaginator = RelatedPaginator(self.relatedListControl, leaf_count=int(self.show_.relatedCount), + parent_window=self) + + self.updateProperties() + self.setBoolProperty("initialized", True) + self.fillEpisodes() + hasSeasons = self.fillSeasons(self.show_, seasonsFilter=lambda x: len(x) > 1, selectSeason=self.season) + hasPrev = self.fillExtras(hasSeasons) + + if not hasPrev and hasSeasons: + hasPrev = True + hasPrev = self.fillRelated(hasPrev) + self.fillRoles(hasPrev) + + def selectEpisode(self, from_select_episode=False): + if not self.episode: + return + + for mli in self.episodeListControl: + if mli.dataSource == self.episode: + self.episodeListControl.selectItem(mli.pos()) + self.episodesPaginator.setEpisode(self.episode) + break + else: + if not from_select_episode: + self.reset(self.episode) + self._setup(from_select_episode=True) + self.postSetup(from_select_episode=True) + + self.episode = None + + def onAction(self, action): + try: + controlID = self.getFocusId() + + if not controlID and self.lastFocusID and not action == xbmcgui.ACTION_MOUSE_MOVE: + self.setFocusId(self.lastFocusID) + + if action == xbmcgui.ACTION_LAST_PAGE and xbmc.getCondVisibility('ControlGroup(300).HasFocus(0)'): + next(self) + elif action == xbmcgui.ACTION_NEXT_ITEM: + next(self) + elif action == xbmcgui.ACTION_FIRST_PAGE and xbmc.getCondVisibility('ControlGroup(300).HasFocus(0)'): + self.prev() + elif action == xbmcgui.ACTION_PREV_ITEM: + self.prev() + + if action == xbmcgui.ACTION_MOVE_UP and controlID in (self.EPISODE_LIST_ID, self.SEASONS_LIST_ID): + self.updateBackgroundFrom((self.show_ or self.season.show())) + + if controlID == self.EPISODE_LIST_ID: + if self.checkForHeaderFocus(action): + return + + elif controlID == self.RELATED_LIST_ID: + if self.relatedPaginator.boundaryHit: + self.relatedPaginator.paginate() + return + elif action in (xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_MOVE_RIGHT): + self.updateBackgroundFrom(self.relatedListControl.getSelectedItem().dataSource) + + if controlID == self.LIST_OPTIONS_BUTTON_ID and self.checkOptionsAction(action): + return + elif action == xbmcgui.ACTION_CONTEXT_MENU: + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + self.lastNonOptionsFocusID = self.lastFocusID + self.setFocusId(self.OPTIONS_GROUP_ID) + return + else: + if self.lastNonOptionsFocusID: + self.setFocusId(self.lastNonOptionsFocusID) + self.lastNonOptionsFocusID = None + return + + elif action == xbmcgui.ACTION_NAV_BACK: + if (not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format( + self.OPTIONS_GROUP_ID)) or not controlID) and \ + not util.advancedSettings.fastBack: + if self.getProperty('on.extras'): + self.setFocusId(self.OPTIONS_GROUP_ID) + return + + if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + self.doClose() + except: + util.ERROR() + + kodigui.ControlledWindow.onAction(self, action) + + def onNewVideo(self, video=None, **kwargs): + if not video: + return + + if not video.type == 'episode': + return + + util.DEBUG_LOG('Updating selected episode: {0}'.format(video)) + self.episode = video + self._reloadVideos.append(video) + + return True + + def checkOptionsAction(self, action): + if action == xbmcgui.ACTION_MOVE_UP: + mli = self.episodeListControl.getSelectedItem() + if not mli or mli.getProperty("is.boundary"): + return False + pos = mli.pos() - 1 + if self.episodeListControl.positionIsValid(pos): + self.setFocusId(self.EPISODE_LIST_ID) + self.episodeListControl.selectItem(pos) + return True + elif action == xbmcgui.ACTION_MOVE_DOWN: + mli = self.episodeListControl.getSelectedItem() + if not mli or mli.getProperty("is.boundary"): + return False + pos = mli.pos() + 1 + if self.episodeListControl.positionIsValid(pos): + self.setFocusId(self.EPISODE_LIST_ID) + self.episodeListControl.selectItem(pos) + return True + + return False + + def onClick(self, controlID): + if controlID == self.HOME_BUTTON_ID: + self.goHome() + elif controlID == self.EPISODE_LIST_ID: + self.episodeListClicked() + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + elif controlID == self.PLAY_BUTTON_ID: + self.playButtonClicked() + elif controlID == self.SHUFFLE_BUTTON_ID: + self.shuffleButtonClicked() + elif controlID == self.OPTIONS_BUTTON_ID: + self.optionsButtonClicked() + elif controlID == self.SETTINGS_BUTTON_ID: + self.settingsButtonClicked() + elif controlID == self.MEDIA_BUTTON_ID: + self.mediaButtonClicked() + elif controlID == self.INFO_BUTTON_ID: + self.infoButtonClicked() + elif controlID == self.SEARCH_BUTTON_ID: + self.searchButtonClicked() + elif controlID == self.SEASONS_LIST_ID: + mli = self.seasonsListControl.getSelectedItem() + if not mli: + return + item = mli.dataSource + if item != self.season: + self.openItem(self.seasonsListControl, came_from=self.season.parentRatingKey) + else: + self.setFocusId(self.EPISODE_LIST_ID) + elif controlID == self.EXTRA_LIST_ID: + self.openItem(self.extraListControl) + elif controlID == self.RELATED_LIST_ID: + self.openItem(self.relatedListControl) + elif controlID == self.ROLES_LIST_ID: + self.roleClicked() + + def onFocus(self, controlID): + self.lastFocusID = controlID + + if 399 < controlID < 500: + self.setProperty('hub.focus', str(controlID - 400)) + if controlID == self.RELATED_LIST_ID: + self.updateBackgroundFrom(self.relatedListControl.getSelectedItem().dataSource) + if xbmc.getCondVisibility('ControlGroup(50).HasFocus(0) + ControlGroup(300).HasFocus(0)'): + self.setProperty('on.extras', '') + elif xbmc.getCondVisibility('ControlGroup(50).HasFocus(0) + !ControlGroup(300).HasFocus(0)'): + self.setProperty('on.extras', '1') + + if player.PLAYER.bgmPlaying and player.PLAYER.handler.currentlyPlaying != self.season.show().ratingKey: + player.PLAYER.stopAndWait() + + def openItem(self, control=None, item=None, came_from=None): + if not item: + mli = control.getSelectedItem() + if not mli: + return + item = mli.dataSource + + self.processCommand(opener.open(item, came_from=came_from)) + + def roleClicked(self): + mli = self.rolesListControl.getSelectedItem() + if not mli: + return + + sectionRoles = busy.widthDialog(mli.dataSource.sectionRoles, '') + + if not sectionRoles: + util.DEBUG_LOG('No sections found for actor') + return + + if len(sectionRoles) > 1: + x, y = self.getRoleItemDDPosition() + + options = [{'role': r, 'display': r.reasonTitle} for r in sectionRoles] + choice = dropdown.showDropdown(options, (x, y), pos_is_bottom=True, close_direction='bottom') + + if not choice: + return + + role = choice['role'] + else: + role = sectionRoles[0] + + self.processCommand(opener.open(role)) + + def getRoleItemDDPosition(self): + y = 980 + if xbmc.getCondVisibility('Control.IsVisible(500)'): + y += 360 + if xbmc.getCondVisibility('Control.IsVisible(501)'): + y += 520 + if xbmc.getCondVisibility('Control.IsVisible(502)'): + y += 520 + if xbmc.getCondVisibility('!String.IsEmpty(Window.Property(on.extras))'): + y -= 125 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),0) + Control.IsVisible(500)'): + y -= 500 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),1) + Control.IsVisible(501)'): + y -= 500 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),1) + Control.IsVisible(502)'): + y -= 500 + + focus = int(xbmc.getInfoLabel('Container(403).Position')) + + x = ((focus + 1) * 304) - 100 + return x, y + + def getSeasons(self): + if not self.seasons: + self.seasons = self.show_.seasons() + + if not self.seasons: + return False + + return True + + def next(self): + if not self._next(): + return + self.setup() + + __next__ = next + + @busy.dialog() + def _next(self): + if self.parentList: + mli = self.parentList.getListItemByDataSource(self.season) + if not mli: + return False + + pos = mli.pos() + 1 + if not self.parentList.positionIsValid(pos): + pos = 0 + + self.season = self.parentList.getListItem(pos).dataSource + else: + if not self.getSeasons(): + return False + + if self.season not in self.seasons: + return False + + pos = self.seasons.index(self.season) + pos += 1 + if pos >= len(self.seasons): + pos = 0 + + self.season = self.seasons[pos] + + return True + + def prev(self): + if not self._prev(): + return + self.setup() + + @busy.dialog() + def _prev(self): + if self.parentList: + mli = self.parentList.getListItemByDataSource(self.season) + if not mli: + return False + + pos = mli.pos() - 1 + if pos < 0: + pos = self.parentList.size() - 1 + + self.season = self.parentList.getListItem(pos).dataSource + else: + if not self.getSeasons(): + return False + + if self.season not in self.seasons: + return False + + pos = self.seasons.index(self.season) + pos -= 1 + if pos < 0: + pos = len(self.seasons) - 1 + + self.season = self.seasons[pos] + + return True + + def searchButtonClicked(self): + section_id = self.show_.getLibrarySectionId() + self.processCommand(search.dialog(self, section_id=section_id or None)) + + def playButtonClicked(self, shuffle=False, force_episode=None): + if shuffle: + seasonOrShow = self.season or self.show_ + items = seasonOrShow.all() + pl = playlist.LocalPlaylist(items, seasonOrShow.getServer()) + + pl.shuffle(shuffle, first=True) + videoplayer.play(play_queue=pl) + return True + + else: + return self.episodeListClicked(force_episode=force_episode) + + def shuffleButtonClicked(self): + self.playButtonClicked(shuffle=True) + + def settingsButtonClicked(self): + mli = self.episodeListControl.getSelectedItem() + if not mli or mli.getProperty("is.boundary"): + return + + episode = mli.dataSource + + if not episode.mediaChoice: + playerObject = plexplayer.PlexPlayer(episode) + playerObject.build() + playersettings.showDialog(video=episode, non_playback=True) + self.setItemAudioAndSubtitleInfo(episode, mli) + + def infoButtonClicked(self): + mli = self.episodeListControl.getSelectedItem() + if not mli or mli.getProperty("is.boundary"): + return + + episode = mli.dataSource + + if episode.index: + subtitle = u'{0} {1} {2} {3}'.format(T(32303, 'Season'), episode.parentIndex, T(32304, 'Episode'), episode.index) + else: + subtitle = episode.originallyAvailableAt.asDatetime('%B %d, %Y') + + opener.handleOpen( + info.InfoWindow, + title=episode.title, + sub_title=subtitle, + thumb=episode.thumb, + thumb_fallback='script.plex/thumb_fallbacks/show.png', + info=episode.summary, + background=self.getProperty('background'), + is_16x9=True, + video=episode + ) + + def episodeListClicked(self, force_episode=None): + if not force_episode: + mli = self.episodeListControl.getSelectedItem() + if not mli or mli.getProperty("is.boundary"): + return + + episode = mli.dataSource + else: + episode = force_episode + + if not episode.available(): + util.messageDialog(T(32312, 'unavailable'), T(32332, 'This item is currently unavailable.')) + return + + resume = False + if episode.viewOffset.asInt(): + choice = dropdown.showDropdown( + options=[ + {'key': 'resume', 'display': T(32429, 'Resume from {0}').format(util.timeDisplay(episode.viewOffset.asInt()).lstrip('0').lstrip(':'))}, + {'key': 'play', 'display': T(32317, 'Play from beginning')} + ], + pos=(660, 441), + close_direction='none', + set_dropdown_prop=False, + header=T(32314, 'In Progress') + ) + + if not choice: + return + + if choice['key'] == 'resume': + resume = True + if util.advancedSettings.dialogFlickerFix: + xbmc.sleep(750) + + self._reloadVideos.append(episode) + + pl = playlist.LocalPlaylist(self.show_.all(), self.show_.getServer()) + try: + if len(pl): # Don't use playlist if it's only this video + pl.setCurrent(episode) + self.processCommand(videoplayer.play(play_queue=pl, resume=resume)) + return True + + self.processCommand(videoplayer.play(video=episode, resume=resume)) + return True + except util.NoDataException: + util.ERROR("No data - disconnected?", notify=True, time_ms=5000) + self.doClose() + + def optionsButtonClicked(self, from_item=False): + options = [] + + mli = self.episodeListControl.getSelectedItem() + + if mli and not mli.getProperty("is.boundary"): + inProgress = mli.dataSource.viewOffset.asInt() + if not mli.dataSource.isWatched or inProgress: + options.append({'key': 'mark_watched', 'display': T(32319, 'Mark Played')}) + if mli.dataSource.isWatched or inProgress: + options.append({'key': 'mark_unwatched', 'display': T(32318, 'Mark Unplayed')}) + + # if True: + # options.append({'key': 'add_to_playlist', 'display': '[COLOR FF808080]Add To Playlist[/COLOR]'}) + + if xbmc.getCondVisibility('Player.HasAudio + MusicPlayer.HasNext'): + options.append({'key': 'play_next', 'display': T(32325, 'Play Next')}) + + if self.season: + if self.season.isWatched: + options.append({'key': 'mark_season_unwatched', 'display': T(32320, 'Mark Season Unplayed')}) + else: + options.append({'key': 'mark_season_watched', 'display': T(32321, 'Mark Season Played')}) + + if self.show_: + if options: + options.append(dropdown.SEPARATOR) + + options.append({'key': 'playback_settings', 'display': T(32925, 'Playback Settings')}) + options.append(dropdown.SEPARATOR) + + if mli.dataSource.server.allowsMediaDeletion: + options.append({'key': 'delete', 'display': T(32322, 'Delete')}) + + # if xbmc.getCondVisibility('Player.HasAudio') and self.section.TYPE == 'artist': + # options.append({'key': 'add_to_queue', 'display': 'Add To Queue'}) + + if options: + options.append(dropdown.SEPARATOR) + + options.append({'key': 'to_show', 'display': T(32323, 'Go To Show')}) + options.append({'key': 'to_section', 'display': T(32324, u'Go to {0}').format( + self.show_.getLibrarySectionTitle())}) + + pos = (500, 620) + bottom = False + setDropdownProp = False + if from_item: + viewPos = self.episodeListControl.getViewPosition() + if viewPos > 6: + pos = (1490, 312 + (viewPos * 100)) + bottom = True + else: + pos = (1490, 167 + (viewPos * 100)) + bottom = False + setDropdownProp = True + + choice = dropdown.showDropdown(options, pos, pos_is_bottom=bottom, close_direction='left', + set_dropdown_prop=setDropdownProp) + if not choice: + return + + if choice['key'] == 'play_next': + xbmc.executebuiltin('PlayerControl(Next)') + elif choice['key'] == 'mark_watched': + mli.dataSource.markWatched() + self.updateItems(mli) + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'mark_unwatched': + mli.dataSource.markUnwatched() + self.updateItems(mli) + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'mark_season_watched': + self.season.markWatched() + self.updateItems() + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'mark_season_unwatched': + self.season.markUnwatched() + self.updateItems() + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'to_show': + if util.advancedSettings.dialogFlickerFix: + xbmc.sleep(750) + self.processCommand(opener.open( + self.season.parentRatingKey, + came_from=self.season.parentRatingKey) + ) + elif choice['key'] == 'to_section': + self.goHome(self.show_.getLibrarySectionId()) + elif choice['key'] == 'delete': + self.delete() + elif choice['key'] == 'playback_settings': + self.playbackSettings(self.show_, pos, bottom) + + def mediaButtonClicked(self): + options = [] + mli = self.episodeListControl.getSelectedItem() + ds = mli.dataSource + for media in ds.media: + ind = '' + if ds.mediaChoice and media.id == ds.mediaChoice.media.id: + ind = 'script.plex/home/device/check.png' + options.append({'key': media, 'display': media.versionString(), 'indicator': ind}) + choice = dropdown.showDropdown(options, header=T(32450, 'Choose Version'), with_indicator=True) + if not choice: + return False + + for media in ds.media: + media.set('selected', '') + + ds.setMediaChoice(choice['key']) + choice['key'].set('selected', 1) + self.setPostReloadItemInfo(ds, mli) + + def delete(self): + button = optionsdialog.show( + T(32326, 'Really delete?'), + T(32327, 'Are you sure you really want to delete this media?'), + T(32328, 'Yes'), + T(32329, 'No') + ) + + if button != 0: + return + + if not self._delete(): + util.messageDialog(T(32330, 'Message'), T(32331, 'There was a problem while attempting to delete the media.')) + + @busy.dialog() + def _delete(self): + mli = self.episodeListControl.getSelectedItem() + if not mli or mli.getProperty("is.boundary"): + return + + video = mli.dataSource + success = video.delete() + util.LOG('Media DELETE: {0} - {1}'.format(video, success and 'SUCCESS' or 'FAILED')) + if success: + self.episodeListControl.removeItem(mli.pos()) + if not self.episodeListControl.size(): + self.doClose() + else: + (self.season or self.show_).reload() + return success + + def checkForHeaderFocus(self, action): + # don't continue if we're still waiting for tasks + if self.tasks or not self.episodesPaginator: + if self.tasks: + util.DEBUG_LOG("Episodes: Moving too fast through paginator, throttling.") + return + + if self.episodesPaginator.boundaryHit: + items = self.episodesPaginator.paginate() + self.reloadItems(items) + return True + + mli = self.episodeListControl.getSelectedItem() + if not mli or mli.getProperty("is.boundary"): + return + + lastItem = self.lastItem + + if action in (xbmcgui.ACTION_MOVE_RIGHT, xbmcgui.ACTION_MOVE_LEFT) and lastItem: + items = self.episodesPaginator.wrap(mli, lastItem, action) + xbmc.sleep(100) + mli = self.episodeListControl.getSelectedItem() + if items: + self.reloadItems(items) + return True + + if mli != self.lastItem and not mli.getProperty("is.boundary"): + self.lastItem = mli + self.setProgress(mli) + + if action in (xbmcgui.ACTION_MOVE_UP, xbmcgui.ACTION_PAGE_UP): + if mli.getProperty('is.header'): + xbmc.executebuiltin('Action(up)') + if action in (xbmcgui.ACTION_MOVE_DOWN, xbmcgui.ACTION_PAGE_DOWN, xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_MOVE_RIGHT): + if mli.getProperty('is.header'): + xbmc.executebuiltin('Action(down)') + + def updateProperties(self): + showTitle = self.show_ and self.show_.title or '' + + self.updateBackgroundFrom(self.show_ or self.season.show()) + self.setProperty('season.thumb', (self.season or self.show_).thumb.asTranscodedImageURL(*self.POSTER_DIM)) + self.setProperty('show.title', showTitle) + self.setProperty('season.title', (self.season or self.show_).title) + + if self.season: + self.setProperty('episodes.header', u'{0} \u2022 {1} {2}'.format(showTitle, T(32303, 'Season'), self.season.index)) + self.setProperty('extras.header', u'{0} \u2022 {1} {2}'.format(T(32305, 'Extras'), T(32303, 'Season'), self.season.index)) + else: + self.setProperty('episodes.header', u'Episodes') + self.setProperty('extras.header', u'Extras') + + self.setProperty('seasons.header', + u'{0} \u2022 {1}'.format(showTitle, T(32942, 'Seasons'))) + self.setProperty('related.header', T(32306, 'Related Shows')) + self.genre = self.show_.genres() and self.show_.genres()[0].tag or '' + + @busy.dialog() + def updateItems(self, item=None): + if item: + item.setProperty('unwatched', not item.dataSource.isWatched and '1' or '') + self.setProgress(item) + item.setProperty('progress', util.getProgressImage(item.dataSource)) + (self.season or self.show_).reload() + else: + self.fillEpisodes(update=True) + + if self.episode: + self.episode.reload() + + def setItemInfo(self, video, mli): + # video.reload(checkFiles=1) + mli.setProperty('background', util.backgroundFromArt(video.art, width=self.width, height=self.height)) + mli.setProperty('title', video.title) + mli.setProperty('show.title', video.grandparentTitle or (self.show_.title if self.show_ else '')) + mli.setProperty('duration', util.durationToText(video.duration.asInt())) + mli.setProperty('summary', video.summary.strip().replace('\t', ' ')) + mli.setProperty('video.rendering', video.videoCodecRendering) + + if video.index: + mli.setProperty('season', u'{0} {1}'.format(T(32303, 'Season'), video.parentIndex)) + mli.setProperty('episode', u'{0} {1}'.format(T(32304, 'Episode'), video.index)) + else: + mli.setProperty('season', '') + mli.setProperty('episode', '') + + mli.setProperty('date', util.cleanLeadingZeros(video.originallyAvailableAt.asDatetime('%B %d, %Y'))) + + # mli.setProperty('related.header', 'Related Shows') + mli.setProperty('year', video.year) + mli.setProperty('content.rating', video.contentRating.split('/', 1)[-1]) + mli.setProperty('genre', self.genre) + + if video.get('userRating'): + stars = str(int(round((video.userRating.asFloat() / 10) * 5))) + mli.setProperty('rating.stars', stars) + # elif video.rating: + # stars = str(int(round((video.rating.asFloat() / 10) * 5))) + # mli.setProperty('rating.stars', stars) + + if video.get('ratingImage'): + rating = video.rating + audienceRating = video.audienceRating + if video.ratingImage.startswith('rottentomatoes:'): + rating = '{0}%'.format(int(rating.asFloat() * 10)) + if audienceRating: + audienceRating = '{0}%'.format(int(audienceRating.asFloat() * 10)) + + mli.setProperty('rating', rating) + mli.setProperty('rating.image', 'script.plex/ratings/{0}.png'.format(video.ratingImage.replace('://', '/'))) + if video.get('audienceRatingImage'): + mli.setProperty('rating2', audienceRating) + mli.setProperty('rating2.image', 'script.plex/ratings/{0}.png'.format(video.audienceRatingImage.replace('://', '/'))) + else: + mli.setProperty('rating', video.rating) + + def setPostReloadItemInfo(self, video, mli): + self.setItemAudioAndSubtitleInfo(video, mli) + mli.setProperty('unwatched', not video.isWatched and '1' or '') + mli.setProperty('video.res', video.resolutionString()) + mli.setProperty('audio.codec', video.audioCodecString()) + mli.setProperty('video.codec', video.videoCodecString()) + mli.setProperty('audio.channels', video.audioChannelsString(metadata.apiTranslate)) + mli.setProperty('video.rendering', video.videoCodecRendering) + mli.setBoolProperty('unavailable', not video.available()) + + defW = 176 + defH = 140 + ids = [301, 302, 303, 304, 305] + if len(list(filter(lambda x: x.isAccessible(), video.media()))) > 1: + mli.setBoolProperty('media.multiple', True) + # adjust button sizes + ids.append(307) + for id in ids: + ctrl = self.getControl(id) + ctrl.setWidth(161) + ctrl.setHeight(125) + del ctrl + else: + mli.setBoolProperty('media.multiple', False) + # reset button sizes + for id in ids: + ctrl = self.getControl(id) + ctrl.setWidth(defW) + ctrl.setHeight(defH) + del ctrl + + def setItemAudioAndSubtitleInfo(self, video, mli): + sas = video.selectedAudioStream() + mli.setProperty('audio', sas and sas.getTitle(metadata.apiTranslate) or T(32309, 'None')) + + sss = video.selectedSubtitleStream(forced_subtitles_override= + util.getSetting("forced_subtitles_override", False)) + if sss: + if len(video.subtitleStreams) > 1: + mli.setProperty( + 'subtitles', u'{0} \u2022 {1} {2}'.format(sss.getTitle(metadata.apiTranslate), len(video.subtitleStreams) - 1, T(32307, 'More')) + ) + else: + mli.setProperty('subtitles', sss.getTitle(metadata.apiTranslate)) + else: + if video.subtitleStreams: + mli.setProperty('subtitles', u'{0} \u2022 {1} {2}'.format(T(32309, 'None'), len(video.subtitleStreams), T(32308, 'Available'))) + else: + mli.setProperty('subtitles', T(32309, 'None')) + + def setProgress(self, mli): + video = mli.dataSource + if video.viewOffset.asInt(): + width = video.viewOffset.asInt() and (1 + int((video.viewOffset.asInt() / video.duration.asFloat()) * self.width)) or 1 + self.progressImageControl.setWidth(width) + else: + self.progressImageControl.setWidth(1) + + if video.viewOffset.asInt(): + mli.setProperty('remainingTime', T(33615, "{time} left").format(time=video.remainingTimeString)) + else: + mli.setProperty('remainingTime', '') + + def createListItem(self, episode): + if episode.index: + subtitle = u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), episode.parentIndex, T(32311, 'E'), episode.index) + else: + subtitle = episode.originallyAvailableAt.asDatetime('%m/%d/%y') + + mli = kodigui.ManagedListItem( + episode.title, + subtitle, + thumbnailImage=episode.thumb.asTranscodedImageURL(*self.THUMB_AR16X9_DIM), + data_source=episode + ) + mli.setProperty('episode.number', str(episode.index) or '') + mli.setProperty('episode.duration', util.durationToText(episode.duration.asInt())) + mli.setProperty('unwatched', not episode.isWatched and '1' or '') + # mli.setProperty('progress', util.getProgressImage(obj)) + return mli + + def fillEpisodes(self, update=False): + items = self.episodesPaginator.paginate() + self.reloadItems(items) + + def reloadItems(self, items, with_progress=False): + tasks = [] + for mli in items: + if not mli.dataSource: + continue + + task = EpisodeReloadTask().setup(mli.dataSource, self.reloadItemCallback, with_progress=with_progress) + self.tasks.add(task) + tasks.append(task) + + backgroundthread.BGThreader.addTasks(tasks) + + def reloadItemCallback(self, task, episode, with_progress=False): + self.tasks.remove(task) + del task + + if self.closing: + return + + selected = self.episodeListControl.getSelectedItem() + + for mli in self.episodeListControl: + if mli.dataSource == episode: + if not episode.mediaChoice: + episode.setMediaChoice() + + self.setPostReloadItemInfo(episode, mli) + if with_progress: + self.episodesPaginator.prepareListItem(None, mli) + if mli == selected: + self.lastItem = mli + self.setProgress(mli) + return + + def fillExtras(self, has_prev=False): + items = [] + idx = 0 + + seasonOrShow = self.season or self.show_ + + if not seasonOrShow.extras: + self.extraListControl.reset() + return False + + for extra in seasonOrShow.extras(): + mli = kodigui.ManagedListItem( + extra.title or '', + metadata.EXTRA_MAP.get(extra.extraType.asInt(), ''), + thumbnailImage=extra.thumb.asTranscodedImageURL(*self.EXTRA_DIM), + data_source=extra + ) + + if mli: + mli.setProperty('index', str(idx)) + mli.setProperty( + 'thumb.fallback', 'script.plex/thumb_fallbacks/{0}.png'.format(extra.type in ('show', 'season', 'episode') and 'show' or 'movie') + ) + items.append(mli) + idx += 1 + + if not items: + return False + + self.extraListControl.reset() + self.extraListControl.addItems(items) + + self.setProperty('divider.{0}'.format(self.EXTRA_LIST_ID), has_prev and '1' or '') + return True + + def fillRelated(self, has_prev=False): + if not self.relatedPaginator.leafCount: + self.relatedListControl.reset() + return has_prev + + items = self.relatedPaginator.paginate() + if not items: + return False + + self.setProperty('divider.{0}'.format(self.RELATED_LIST_ID), has_prev and '1' or '') + + return True + + def fillRoles(self, has_prev=False): + items = [] + idx = 0 + + if not self.show_.roles: + self.rolesListControl.reset() + return False + + for role in self.show_.roles(): + mli = kodigui.ManagedListItem(role.tag, role.role, thumbnailImage=role.thumb.asTranscodedImageURL(*self.ROLES_DIM), data_source=role) + mli.setProperty('index', str(idx)) + items.append(mli) + idx += 1 + + if not items: + return False + + self.setProperty('divider.{0}'.format(self.ROLES_LIST_ID), has_prev and '1' or '') + + self.rolesListControl.reset() + self.rolesListControl.addItems(items) + return True diff --git a/script.plexmod/lib/windows/home.py b/script.plexmod/lib/windows/home.py new file mode 100644 index 000000000..751292e78 --- /dev/null +++ b/script.plexmod/lib/windows/home.py @@ -0,0 +1,1389 @@ +from __future__ import absolute_import +import time +import threading + +from kodi_six import xbmc +from kodi_six import xbmcgui + +from . import kodigui +from lib import util +from lib import backgroundthread +from lib import player + +import plexnet +from plexnet import plexapp + +from . import windowutils +from . import playlists +from . import busy +from . import opener +from . import search +from . import optionsdialog + +from lib.util import T +from six.moves import range + + +HUBS_REFRESH_INTERVAL = 300 # 5 Minutes +HUB_PAGE_SIZE = 10 + +MOVE_SET = frozenset( + ( + xbmcgui.ACTION_MOVE_LEFT, + xbmcgui.ACTION_MOVE_RIGHT, + xbmcgui.ACTION_MOVE_UP, + xbmcgui.ACTION_MOVE_DOWN, + xbmcgui.ACTION_MOUSE_MOVE, + xbmcgui.ACTION_PAGE_UP, + xbmcgui.ACTION_PAGE_DOWN, + xbmcgui.ACTION_FIRST_PAGE, + xbmcgui.ACTION_LAST_PAGE, + xbmcgui.ACTION_MOUSE_WHEEL_DOWN, + xbmcgui.ACTION_MOUSE_WHEEL_UP + ) +) + + +class HubsList(list): + def init(self): + self.lastUpdated = time.time() + return self + + +class SectionHubsTask(backgroundthread.Task): + def setup(self, section, callback): + self.section = section + self.callback = callback + return self + + def run(self): + if self.isCanceled(): + return + + if not plexapp.SERVERMANAGER.selectedServer: + # Could happen during sign-out for instance + return + + try: + hubs = HubsList(plexapp.SERVERMANAGER.selectedServer.hubs(self.section.key, count=HUB_PAGE_SIZE)).init() + if self.isCanceled(): + return + self.callback(self.section, hubs) + except plexnet.exceptions.BadRequest: + util.DEBUG_LOG('404 on section: {0}'.format(repr(self.section.title))) + self.callback(self.section, False) + except TypeError: + util.ERROR("No data - disconnected?", notify=True, time_ms=5000) + self.cancel() + + +class UpdateHubTask(backgroundthread.Task): + def setup(self, hub, callback): + self.hub = hub + self.callback = callback + return self + + def run(self): + if self.isCanceled(): + return + + if not plexapp.SERVERMANAGER.selectedServer: + # Could happen during sign-out for instance + return + + try: + self.hub.reload() + if self.isCanceled(): + return + self.callback(self.hub) + except plexnet.exceptions.BadRequest: + util.DEBUG_LOG('404 on section: {0}'.format(repr(self.section.title))) + + +class ExtendHubTask(backgroundthread.Task): + def setup(self, hub, callback, canceledCallback=None): + self.hub = hub + self.callback = callback + self.canceledCallback = canceledCallback + return self + + def run(self): + if self.isCanceled(): + if self.canceledCallback: + self.canceledCallback(self.hub) + return + + if not plexapp.SERVERMANAGER.selectedServer: + # Could happen during sign-out for instance + return + + try: + start = self.hub.offset.asInt() + self.hub.size.asInt() + items = self.hub.extend(start=start, size=HUB_PAGE_SIZE) + if self.isCanceled(): + if self.canceledCallback: + self.canceledCallback(self.hub) + return + self.callback(self.hub, items) + except plexnet.exceptions.BadRequest: + util.DEBUG_LOG('404 on hub: {0}'.format(repr(self.hub.hubIdentifier))) + if self.canceledCallback: + self.canceledCallback(self.hub) + + +class HomeSection(object): + key = None + type = 'home' + title = T(32332, 'Home') + + +class PlaylistsSection(object): + key = 'playlists' + type = 'playlists' + title = T(32333, 'Playlists') + + +class ServerListItem(kodigui.ManagedListItem): + uuid = None + + def hookSignals(self): + self.dataSource.on('completed:reachability', self.onReachability) + self.dataSource.on('started:reachability', self.onReachability) + + def unHookSignals(self): + try: + self.dataSource.off('completed:reachability', self.onReachability) + self.dataSource.off('started:reachability', self.onReachability) + except: + pass + + def setRefreshing(self): + self.safeSetProperty('status', 'refreshing.gif') + + def safeSetProperty(self, key, value): + # For if we catch the item in the middle of being removed + try: + self.setProperty(key, value) + return True + except AttributeError: + pass + + return False + + def safeSetLabel(self, value, func="setLabel"): + if value is None: + return False + try: + getattr(self, func)(value) + return True + except AttributeError: + pass + + return False + + def safeGetDSProperty(self, prop): + return getattr(self.dataSource, prop, None) + + def onReachability(self, **kwargs): + plexapp.util.APP.trigger('sli:reachability:received') + return self.onUpdate(**kwargs) + + def onUpdate(self, **kwargs): + if not self.listItem: # ex. can happen on Kodi shutdown + return + + if self.dataSource == kodigui.DUMMY_DATA_SOURCE: + return + + # this looks a little ridiculous, but we're experiencing timing issues here + isSupported = self.safeGetDSProperty("isSupported") + isReachable = False + isReachableFunc = self.safeGetDSProperty("isReachable") + isSecure = self.safeGetDSProperty("isSecure") + isLocal = self.safeGetDSProperty("isLocal") + name = self.safeGetDSProperty("name") + pendingReachabilityRequests = self.safeGetDSProperty("pendingReachabilityRequests") + owned = not self.safeGetDSProperty("owned") and self.safeGetDSProperty("owner") or '' + if isReachableFunc: + isReachable = isReachableFunc() + + if not isSupported or not isReachable: + if pendingReachabilityRequests is not None and pendingReachabilityRequests > 0: + self.safeSetProperty('status', 'refreshing.gif') + else: + self.safeSetProperty('status', 'unreachable.png') + else: + self.safeSetProperty('status', isSecure and 'secure.png' or '') + self.safeSetProperty('secure', isSecure and '1' or '') + self.safeSetProperty('local', isLocal and '1' or '') + + self.safeSetProperty('current', plexapp.SERVERMANAGER.selectedServer.uuid == self.uuid and '1' or '') + if name: + self.safeSetLabel(name) + + if owned: + self.safeSetLabel(owned, func="setLabel2") + + def onDestroy(self): + self.unHookSignals() + + +class HomeWindow(kodigui.BaseWindow, util.CronReceiver): + xmlFile = 'script-plex-home.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + OPTIONS_GROUP_ID = 200 + + SECTION_LIST_ID = 101 + SERVER_BUTTON_ID = 201 + + USER_BUTTON_ID = 202 + USER_LIST_ID = 250 + + SEARCH_BUTTON_ID = 203 + SERVER_LIST_ID = 260 + REFRESH_SL_ID = 262 + + PLAYER_STATUS_BUTTON_ID = 204 + + HUB_AR16X9_00 = 400 + HUB_POSTER_01 = 401 + HUB_POSTER_02 = 402 + HUB_POSTER_03 = 403 + HUB_POSTER_04 = 404 + HUB_SQUARE_05 = 405 + HUB_AR16X9_06 = 406 + HUB_POSTER_07 = 407 + HUB_POSTER_08 = 408 + HUB_SQUARE_09 = 409 + HUB_SQUARE_10 = 410 + HUB_SQUARE_11 = 411 + HUB_SQUARE_12 = 412 + HUB_POSTER_13 = 413 + HUB_POSTER_14 = 414 + HUB_POSTER_15 = 415 + HUB_POSTER_16 = 416 + HUB_AR16X9_17 = 417 + HUB_AR16X9_18 = 418 + HUB_AR16X9_19 = 419 + + HUB_SQUARE_20 = 420 + HUB_SQUARE_21 = 421 + HUB_SQUARE_22 = 422 + + HUB_AR16X9_23 = 423 + + HUBMAP = { + # HOME + 'home.continue': {'index': 0, 'with_progress': True, 'with_art': True, 'do_updates': True, 'text2lines': True}, + 'home.ondeck': {'index': 1, 'with_progress': True, 'do_updates': True, 'text2lines': True}, + 'home.television.recent': {'index': 2, 'do_updates': True, 'with_progress': True, 'text2lines': True}, + 'home.movies.recent': {'index': 4, 'do_updates': True, 'with_progress': True, 'text2lines': True}, + 'home.music.recent': {'index': 5, 'text2lines': True}, + 'home.videos.recent': {'index': 6, 'with_progress': True, 'ar16x9': True}, + #'home.playlists': {'index': 9}, # No other Plex home screen shows playlists so removing it from here + 'home.photos.recent': {'index': 10, 'text2lines': True}, + # SHOW + 'tv.ondeck': {'index': 1, 'with_progress': True, 'do_updates': True, 'text2lines': True}, + 'tv.recentlyaired': {'index': 2, 'do_updates': True, 'with_progress': True, 'text2lines': True}, + 'tv.recentlyadded': {'index': 3, 'do_updates': True, 'with_progress': True, 'text2lines': True}, + 'tv.inprogress': {'index': 4, 'with_progress': True, 'do_updates': True, 'text2lines': True}, + 'tv.startwatching': {'index': 7, 'with_progress': True, 'do_updates': True}, + 'tv.rediscover': {'index': 8, 'with_progress': True, 'do_updates': True}, + 'tv.morefromnetwork': {'index': 13, 'with_progress': True, 'do_updates': True}, + 'tv.toprated': {'index': 14, 'with_progress': True, 'do_updates': True}, + 'tv.moreingenre': {'index': 15, 'with_progress': True, 'do_updates': True}, + 'tv.recentlyviewed': {'index': 16, 'with_progress': True, 'text2lines': True, 'do_updates': True}, + # MOVIE + 'movie.inprogress': {'index': 0, 'with_progress': True, 'with_art': True, 'do_updates': True, 'text2lines': True}, + 'movie.recentlyreleased': {'index': 1, 'do_updates': True, 'with_progress': True, 'text2lines': True}, + 'movie.recentlyadded': {'index': 2, 'do_updates': True, 'with_progress': True, 'text2lines': True}, + 'movie.genre': {'index': 3, 'with_progress': True, 'text2lines': True, 'do_updates': True}, + 'movie.by.actor.or.director': {'index': 7, 'with_progress': True, 'text2lines': True, 'do_updates': True}, + 'movie.topunwatched': {'index': 13, 'text2lines': True, 'do_updates': True}, + 'movie.recentlyviewed': {'index': 14, 'with_progress': True, 'text2lines': True, 'do_updates': True}, + # ARTIST + 'music.recent.played': {'index': 5, 'do_updates': True}, + 'music.recent.added': {'index': 9, 'text2lines': True}, + 'music.recent.artist': {'index': 10, 'text2lines': True}, + 'music.recent.genre': {'index': 11, 'text2lines': True}, + 'music.top.period': {'index': 12, 'text2lines': True}, + 'music.popular': {'index': 20, 'text2lines': True}, + 'music.recent.label': {'index': 21, 'text2lines': True}, + 'music.touring': {'index': 22}, + 'music.videos.popular.new': {'index': 18}, + 'music.videos.new': {'index': 19}, + 'music.videos.recent.artists': {'index': 23}, + # PHOTO + 'photo.recent': {'index': 5, 'text2lines': True}, + 'photo.random.year': {'index': 9, 'text2lines': True}, + 'photo.random.decade': {'index': 10, 'text2lines': True}, + 'photo.random.dayormonth': {'index': 11, 'text2lines': True}, + # VIDEO + 'video.recent': {'index': 0, 'with_progress': True, 'ar16x9': True}, + 'video.random.year': {'index': 6, 'with_progress': True, 'ar16x9': True}, + 'video.random.decade': {'index': 17, 'with_progress': True, 'ar16x9': True}, + 'video.inprogress': {'index': 18, 'with_progress': True, 'ar16x9': True}, + 'video.unwatched.random': {'index': 19, 'ar16x9': True}, + 'video.recentlyviewed': {'index': 23, 'with_progress': True, 'ar16x9': True}, + # PLAYLISTS + 'playlists.audio': {'index': 5, 'text2lines': True, 'title': T(32048, 'Audio')}, + 'playlists.video': {'index': 6, 'text2lines': True, 'ar16x9': True, 'title': T(32053, 'Video')}, + } + + THUMB_POSTER_DIM = util.scaleResolution(244, 361) + THUMB_AR16X9_DIM = util.scaleResolution(532, 299) + THUMB_SQUARE_DIM = util.scaleResolution(244, 244) + + def __init__(self, *args, **kwargs): + kodigui.BaseWindow.__init__(self, *args, **kwargs) + self.lastSection = HomeSection + self.tasks = [] + self.closeOption = None + self.hubControls = None + self.backgroundSet = False + self.sectionChangeThread = None + self.lastFocusID = None + self.lastNonOptionsFocusID = None + self.sectionHubs = {} + self.updateHubs = {} + self.changingServer = False + self._shuttingDown = False + windowutils.HOME = self + + self.lock = threading.Lock() + + util.setGlobalBoolProperty('off.sections', '') + + def onFirstInit(self): + # set last BG image if possible + if util.advancedSettings.dynamicBackgrounds: + bgUrl = util.getSetting("last_bg_url") + if bgUrl: + self.windowSetBackground(bgUrl) + + # set good volume if we've missed re-setting BGM volume before + lastGoodVlm = util.getSetting('last_good_volume', 0) + BGMVlm = plexapp.util.INTERFACE.getThemeMusicValue() + if lastGoodVlm and BGMVlm and util.rpc.Application.GetProperties(properties=["volume"])["volume"] == BGMVlm: + util.DEBUG_LOG("Setting volume to {}, we probably missed the " + "re-set on the last BGM encounter".format(lastGoodVlm)) + xbmc.executebuiltin("SetVolume({})".format(lastGoodVlm)) + + self.sectionList = kodigui.ManagedControlList(self, self.SECTION_LIST_ID, 7) + self.serverList = kodigui.ManagedControlList(self, self.SERVER_LIST_ID, 10) + self.userList = kodigui.ManagedControlList(self, self.USER_LIST_ID, 3) + + self.hubControls = ( + kodigui.ManagedControlList(self, self.HUB_AR16X9_00, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_01, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_02, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_03, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_04, 5), + kodigui.ManagedControlList(self, self.HUB_SQUARE_05, 5), + kodigui.ManagedControlList(self, self.HUB_AR16X9_06, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_07, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_08, 5), + kodigui.ManagedControlList(self, self.HUB_SQUARE_09, 5), + kodigui.ManagedControlList(self, self.HUB_SQUARE_10, 5), + kodigui.ManagedControlList(self, self.HUB_SQUARE_11, 5), + kodigui.ManagedControlList(self, self.HUB_SQUARE_12, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_13, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_14, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_15, 5), + kodigui.ManagedControlList(self, self.HUB_POSTER_16, 5), + kodigui.ManagedControlList(self, self.HUB_AR16X9_17, 5), + kodigui.ManagedControlList(self, self.HUB_AR16X9_18, 5), + kodigui.ManagedControlList(self, self.HUB_AR16X9_19, 5), + kodigui.ManagedControlList(self, self.HUB_SQUARE_20, 5), + kodigui.ManagedControlList(self, self.HUB_SQUARE_21, 5), + kodigui.ManagedControlList(self, self.HUB_SQUARE_22, 5), + kodigui.ManagedControlList(self, self.HUB_AR16X9_23, 5), + ) + + self.hubFocusIndexes = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 17, 18, 19, 20, 21, 22, 13, 14, 15, 23) + + self.bottomItem = 0 + if self.serverRefresh(): + self.setFocusId(self.SECTION_LIST_ID) + + self.hookSignals() + util.CRON.registerReceiver(self) + + def onReInit(self): + if self.lastFocusID: + # try focusing the last focused ID. if that's a hub and it's empty (=not focusable), try focusing the + # next best hub + if 399 < self.lastFocusID < 500: + hubControlIndex = self.lastFocusID - 400 + + if hubControlIndex in self.hubFocusIndexes and self.hubControls[hubControlIndex]: + # this is basically just used for setting the background upon reinit + # fixme: declutter, separation of concerns + self.checkHubItem(self.lastFocusID) + else: + util.DEBUG_LOG("Focus requested on {}, which can't focus. Trying next hub".format(self.lastFocusID)) + self.focusFirstValidHub(hubControlIndex) + + else: + self.setFocusId(self.lastFocusID) + + def focusFirstValidHub(self, startIndex=None): + indices = self.hubFocusIndexes + if startIndex is not None: + try: + indices = self.hubFocusIndexes[self.hubFocusIndexes.index(startIndex):] + util.DEBUG_LOG("Trying to focus the next best hub after: %i" % (400 + startIndex)) + except IndexError: + pass + + for index in indices: + if self.hubControls[index]: + util.DEBUG_LOG("Focusing hub: %i" % (400 + index)) + self.setFocusId(400+index) + self.checkHubItem(400+index) + return + + if startIndex is not None: + util.DEBUG_LOG("Tried all possible hubs after %i. Continuing from the top" % (400 + startIndex)) + else: + util.DEBUG_LOG("Can't find any suitable hub to focus. This is bad.") + self.setFocusId(self.SECTION_LIST_ID) + return + + return self.focusFirstValidHub() + + def hookSignals(self): + plexapp.SERVERMANAGER.on('new:server', self.onNewServer) + plexapp.SERVERMANAGER.on('remove:server', self.onRemoveServer) + plexapp.SERVERMANAGER.on('reachable:server', self.onReachableServer) + plexapp.SERVERMANAGER.on('reachable:server', self.displayServerAndUser) + + plexapp.util.APP.on('change:selectedServer', self.onSelectedServerChange) + plexapp.util.APP.on('account:response', self.displayServerAndUser) + plexapp.util.APP.on('sli:reachability:received', self.displayServerAndUser) + + player.PLAYER.on('session.ended', self.updateOnDeckHubs) + util.MONITOR.on('changed.watchstatus', self.updateOnDeckHubs) + + def unhookSignals(self): + plexapp.SERVERMANAGER.off('new:server', self.onNewServer) + plexapp.SERVERMANAGER.off('remove:server', self.onRemoveServer) + plexapp.SERVERMANAGER.off('reachable:server', self.onReachableServer) + plexapp.SERVERMANAGER.off('reachable:server', self.displayServerAndUser) + + plexapp.util.APP.off('change:selectedServer', self.onSelectedServerChange) + plexapp.util.APP.off('account:response', self.displayServerAndUser) + + player.PLAYER.off('session.ended', self.updateOnDeckHubs) + util.MONITOR.off('changed.watchstatus', self.updateOnDeckHubs) + + def tick(self): + if not self.lastSection: + return + + hubs = self.sectionHubs.get(self.lastSection.key) + if not hubs: + return + + if time.time() - hubs.lastUpdated > HUBS_REFRESH_INTERVAL: + self.showHubs(self.lastSection, update=True) + + def shutdown(self): + self._shuttingDown = True + try: + self.serverList.reset() + except AttributeError: + pass + + self.unhookSignals() + + if util.advancedSettings.dynamicBackgrounds: + # store BG url of first hub, first item, as this is most likely to be the one we're focusing on the + # next start + try: + indices = self.hubFocusIndexes + for index in indices: + if self.hubControls[index]: + ds = self.hubControls[index][0].dataSource + util.setSetting("last_bg_url", + util.backgroundFromArt(ds.art, width=self.width, height=self.height)) + return + except: + util.LOG("Couldn't store last background") + + def onAction(self, action): + controlID = self.getFocusId() + + try: + if not controlID and not action == xbmcgui.ACTION_MOUSE_MOVE: + if self.lastFocusID: + self.setFocusId(self.lastFocusID) + + if controlID == self.SECTION_LIST_ID: + self.checkSectionItem(action=action) + + if controlID == self.SERVER_BUTTON_ID: + if action == xbmcgui.ACTION_SELECT_ITEM: + self.showServers() + return + elif action == xbmcgui.ACTION_MOUSE_LEFT_CLICK: + self.showServers(mouse=True) + self.setBoolProperty('show.servers', True) + return + elif controlID == self.USER_BUTTON_ID: + if action == xbmcgui.ACTION_SELECT_ITEM: + self.showUserMenu() + return + elif action == xbmcgui.ACTION_MOUSE_LEFT_CLICK: + self.showUserMenu(mouse=True) + self.setBoolProperty('show.options', True) + return + elif controlID == self.SERVER_LIST_ID: + if action == xbmcgui.ACTION_SELECT_ITEM: + self.setFocusId(self.SERVER_BUTTON_ID) + return + + if controlID == self.SERVER_BUTTON_ID and action == xbmcgui.ACTION_MOVE_RIGHT: + self.setFocusId(self.USER_BUTTON_ID) + elif controlID == self.USER_BUTTON_ID and action == xbmcgui.ACTION_MOVE_LEFT: + self.setFocusId(self.SERVER_BUTTON_ID) + elif controlID == self.SEARCH_BUTTON_ID and action == xbmcgui.ACTION_MOVE_RIGHT: + if xbmc.getCondVisibility('Player.HasMedia + Control.IsVisible({0})'.format(self.PLAYER_STATUS_BUTTON_ID)): + self.setFocusId(self.PLAYER_STATUS_BUTTON_ID) + else: + self.setFocusId(self.SERVER_BUTTON_ID) + elif controlID == self.PLAYER_STATUS_BUTTON_ID and action == xbmcgui.ACTION_MOVE_RIGHT: + self.setFocusId(self.SERVER_BUTTON_ID) + elif 399 < controlID < 500: + if action.getId() in MOVE_SET: + self.checkHubItem(controlID) + elif action.getId() == xbmcgui.ACTION_PLAYER_PLAY: + self.hubItemClicked(controlID, auto_play=True) + return + + if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_CONTEXT_MENU): + optionsFocused = xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)) + offSections = util.getGlobalProperty('off.sections') + if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + # fixme: cheap way of avoiding an early exit after a server change + if self.changingServer: + return + + if self.getFocusId() == self.USER_LIST_ID: + self.setFocusId(self.USER_BUTTON_ID) + return + elif self.getFocusId() == self.SERVER_LIST_ID: + self.setFocusId(self.SERVER_BUTTON_ID) + return + + if util.advancedSettings.fastBack and not optionsFocused and offSections \ + and self.lastFocusID not in (self.USER_BUTTON_ID, self.SERVER_BUTTON_ID, + self.SEARCH_BUTTON_ID, self.SECTION_LIST_ID): + self.setProperty('hub.focus', '0') + self.setFocusId(self.SECTION_LIST_ID) + return + + if action in(xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): + if not optionsFocused and offSections \ + and (not util.advancedSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU): + self.lastNonOptionsFocusID = self.lastFocusID + self.setFocusId(self.OPTIONS_GROUP_ID) + return + elif action == xbmcgui.ACTION_CONTEXT_MENU and optionsFocused and offSections \ + and self.lastNonOptionsFocusID: + self.setFocusId(self.lastNonOptionsFocusID) + self.lastNonOptionsFocusID = None + return + + if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + ex = self.confirmExit() + # 0 = exit; 1 = minimize; 2 = cancel + if ex in (2, None): + return + elif ex == 1: + xbmc.executebuiltin('ActivateWindow(10000)') + return + elif ex == 0: + self._shuttingDown = True + # 0 passes the action to the BaseWindow and exits HOME + except: + util.ERROR() + + kodigui.BaseWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.SECTION_LIST_ID: + if self.sectionChangeThread and self.sectionChangeThread.is_alive(): + self.sectionChangeThread.cancel() + self._sectionChanged() + return + self.sectionClicked() + # elif controlID == self.SERVER_BUTTON_ID: + # self.showServers() + elif controlID == self.SERVER_LIST_ID: + self.setBoolProperty('show.servers', False) + self.selectServer() + # elif controlID == self.USER_BUTTON_ID: + # self.showUserMenu() + elif controlID == self.USER_LIST_ID: + self.doUserOption() + self.setBoolProperty('show.options', False) + self.setFocusId(self.USER_BUTTON_ID) + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + elif 399 < controlID < 500: + self.hubItemClicked(controlID) + elif controlID == self.SEARCH_BUTTON_ID: + self.searchButtonClicked() + + def onFocus(self, controlID): + if controlID != 204: + # don't store focus for mini music player + self.lastFocusID = controlID + + if 399 < controlID < 500: + self.setProperty('hub.focus', str(self.hubFocusIndexes[controlID - 400])) + + if controlID == self.SECTION_LIST_ID and not self.changingServer: + self.checkSectionItem() + + if xbmc.getCondVisibility('ControlGroup(50).HasFocus(0) + ControlGroup(100).HasFocus(0)'): + util.setGlobalBoolProperty('off.sections', '') + elif controlID != 250 and xbmc.getCondVisibility('ControlGroup(50).HasFocus(0) + !ControlGroup(100).HasFocus(0)'): + util.setGlobalBoolProperty('off.sections', '1') + + if player.PLAYER.bgmPlaying: + player.PLAYER.stopAndWait() + + def confirmExit(self): + button = optionsdialog.show( + T(32334, 'Confirm Exit'), + T(32335, 'Are you ready to exit Plex?'), + T(32336, 'Exit'), + T(32924, 'Minimize'), + T(32337, 'Cancel') + ) + + return button + + def searchButtonClicked(self): + self.processCommand(search.dialog(self)) + + def updateOnDeckHubs(self, **kwargs): + if util.getSetting("speedy_home_hubs2", False): + util.DEBUG_LOG("Using alternative home hub refresh") + sections = set() + for mli in self.sectionList: + if mli.dataSource is not None and mli.dataSource != self.lastSection: + sections.add(mli.dataSource) + tasks = [SectionHubsTask().setup(s, self.sectionHubsCallback) for s in [self.lastSection] + list(sections)] + else: + tasks = [UpdateHubTask().setup(hub, self.updateHubCallback) for hub in self.updateHubs.values()] + self.tasks += tasks + backgroundthread.BGThreader.addTasks(tasks) + + def showBusy(self, on=True): + self.setProperty('busy', on and '1' or '') + + @busy.dialog() + def serverRefresh(self): + backgroundthread.BGThreader.reset() + if self.tasks: + for task in self.tasks: + task.cancel() + + with self.lock: + self.setProperty('hub.focus', '') + self.displayServerAndUser() + if not plexapp.SERVERMANAGER.selectedServer: + self.setFocusId(self.USER_BUTTON_ID) + return False + + self.showSections() + self.backgroundSet = False + self.showHubs(HomeSection) + return True + + def hubItemClicked(self, hubControlID, auto_play=False): + control = self.hubControls[hubControlID - 400] + mli = control.getSelectedItem() + if not mli: + return + + if mli.dataSource is None: + return + + try: + command = opener.open(mli.dataSource, auto_play=auto_play) + if command == "NODATA": + raise util.NoDataException + except util.NoDataException: + util.ERROR("No data - disconnected?", notify=True, time_ms=5000) + return + + self.updateListItem(mli) + + if not mli: + return + + if not mli.dataSource.exists(): + try: + control.removeItem(mli.pos()) + except (ValueError, TypeError): + # fixme: why? + pass + + if not control.size(): + idx = self.hubFocusIndexes[hubControlID - 400] + while idx > 0: + idx -= 1 + controlID = 400 + self.hubFocusIndexes.index(idx) + control = self.hubControls[self.hubFocusIndexes.index(idx)] + if control.size(): + self.setFocusId(controlID) + break + else: + self.setFocusId(self.SECTION_LIST_ID) + + self.processCommand(command) + + def processCommand(self, command): + if command.startswith('HOME:'): + sectionID = command.split(':', 1)[-1] + for mli in self.sectionList: + if mli.dataSource and mli.dataSource.key == sectionID: + self.sectionList.selectItem(mli.pos()) + self.lastSection = mli.dataSource + self.sectionChanged() + + def checkSectionItem(self, force=False, action=None): + item = self.sectionList.getSelectedItem() + if not item: + return + + if not item.getProperty('item'): + if action and action == xbmcgui.ACTION_MOVE_RIGHT: + self.sectionList.selectItem(0) + item = self.sectionList[0] + else: + self.sectionList.selectItem(self.bottomItem) + item = self.sectionList[self.bottomItem] + + if item.dataSource != self.lastSection: + self.lastSection = item.dataSource + self.sectionChanged(force) + + def checkHubItem(self, controlID): + control = self.hubControls[controlID - 400] + mli = control.getSelectedItem() + is_valid_mli = mli and mli.getProperty('is.end') != '1' + + if util.advancedSettings.dynamicBackgrounds and is_valid_mli: + self.updateBackgroundFrom(mli.dataSource) + + if not mli or not mli.getProperty('is.end') or mli.getProperty('is.updating') == '1': + return + + mli.setBoolProperty('is.updating', True) + self.cleanTasks() + task = ExtendHubTask().setup(control.dataSource, self.extendHubCallback, + canceledCallback=lambda hub: mli.setBoolProperty('is.updating', False)) + self.tasks.append(task) + backgroundthread.BGThreader.addTask(task) + + def displayServerAndUser(self, **kwargs): + title = plexapp.ACCOUNT.title or plexapp.ACCOUNT.username or ' ' + self.setProperty('user.name', title) + self.setProperty('user.avatar', plexapp.ACCOUNT.thumb) + self.setProperty('user.avatar.letter', title[0].upper()) + + if plexapp.SERVERMANAGER.selectedServer: + self.setProperty('server.name', plexapp.SERVERMANAGER.selectedServer.name) + self.setProperty('server.icon', + 'script.plex/home/device/plex.png') # TODO: Set dynamically to whatever it should be if that's how it even works :) + self.setProperty('server.iconmod', + plexapp.SERVERMANAGER.selectedServer.isSecure and 'script.plex/home/device/lock.png' or '') + self.setProperty('server.iconmod2', + plexapp.SERVERMANAGER.selectedServer.isLocal and 'script.plex/home/device/home_small.png' + or '') + else: + self.setProperty('server.name', T(32338, 'No Servers Found')) + self.setProperty('server.icon', 'script.plex/home/device/error.png') + self.setProperty('server.iconmod', '') + self.setProperty('server.iconmod2', '') + + def cleanTasks(self): + self.tasks = [t for t in self.tasks if t.isValid()] + + def sectionChanged(self, force=False): + """ + fixme: force is probably invalid or never used; check + """ + if self.sectionChangeThread and self.sectionChangeThread.is_alive(): + self.sectionChangeThread.cancel() + self.sectionChangeThread.join() + + if not self.sectionChangeThread or not self.sectionChangeThread.is_alive() or force: + if not force: + self.sectionChangeThread = threading.Timer(0.5, self._sectionChanged) + self.sectionChangeThread.start() + else: + self._sectionReallyChanged() + + def _sectionChanged(self): + self._sectionReallyChanged() + + def _sectionReallyChanged(self): + with self.lock: + section = self.lastSection + self.setProperty('hub.focus', '') + if util.advancedSettings.dynamicBackgrounds: + self.backgroundSet = False + + util.DEBUG_LOG('Section changed ({0}): {1}'.format(section.key, repr(section.title))) + self.showHubs(section) + self.lastSection = section + self.checkSectionItem(force=True) + + def sectionHubsCallback(self, section, hubs): + with self.lock: + update = bool(self.sectionHubs.get(section.key)) + self.sectionHubs[section.key] = hubs + if self.lastSection == section: + self.showHubs(section, update=update) + + def updateHubCallback(self, hub, items=None): + with self.lock: + for mli in self.sectionList: + section = mli.dataSource + if not section: + continue + + hubs = self.sectionHubs.get(section.key, ()) + for idx, ihub in enumerate(hubs): + if ihub == hub: + if self.lastSection == section: + util.DEBUG_LOG('Hub {0} updated - refreshing section: {1}'.format(hub.hubIdentifier, repr(section.title))) + hubs[idx] = hub + self.showHub(hub, items=items) + return + + def extendHubCallback(self, hub, items): + self.updateHubCallback(hub, items) + + def showSections(self): + self.sectionHubs = {} + items = [] + + homemli = kodigui.ManagedListItem(T(32332, 'Home'), data_source=HomeSection) + homemli.setProperty('is.home', '1') + homemli.setProperty('item', '1') + items.append(homemli) + + pl = plexapp.SERVERMANAGER.selectedServer.playlists() + if pl: + plli = kodigui.ManagedListItem('Playlists', thumbnailImage='script.plex/home/type/playlists.png', data_source=PlaylistsSection) + plli.setProperty('is.playlists', '1') + plli.setProperty('item', '1') + items.append(plli) + + try: + sections = plexapp.SERVERMANAGER.selectedServer.library.sections() + except plexnet.exceptions.BadRequest: + self.setFocusId(self.SERVER_BUTTON_ID) + util.messageDialog("Error", "Bad request") + return + + if plexapp.SERVERMANAGER.selectedServer.hasHubs(): + self.tasks = [SectionHubsTask().setup(s, self.sectionHubsCallback) for s in [HomeSection, PlaylistsSection] + sections] + backgroundthread.BGThreader.addTasks(self.tasks) + + for section in sections: + mli = kodigui.ManagedListItem(section.title, thumbnailImage='script.plex/home/type/{0}.png'.format(section.type), data_source=section) + mli.setProperty('item', '1') + items.append(mli) + + self.bottomItem = len(items) - 1 + + for x in range(len(items), 8): + mli = kodigui.ManagedListItem() + items.append(mli) + + self.lastSection = HomeSection + self.sectionList.reset() + self.sectionList.addItems(items) + + if items: + self.setFocusId(self.SECTION_LIST_ID) + else: + self.setFocusId(self.SERVER_BUTTON_ID) + + def showHubs(self, section=None, update=False): + self.setBoolProperty('no.content', False) + if not update: + self.setProperty('drawing', '1') + try: + self._showHubs(section=section, update=update) + finally: + self.setProperty('drawing', '') + + def _showHubs(self, section=None, update=False): + if not update: + self.clearHubs() + + if not plexapp.SERVERMANAGER.selectedServer.hasHubs(): + return + + if section.key is False: + self.showBusy(False) + return + + self.showBusy(True) + + hubs = self.sectionHubs.get(section.key) + if hubs is False: + self.showBusy(False) + self.setBoolProperty('no.content', True) + return + + if not hubs: + for task in self.tasks: + if task.section == section: + backgroundthread.BGThreader.moveToFront(task) + break + + if section.type != "home": + self.showBusy(False) + self.setBoolProperty('no.content', True) + return + + if time.time() - hubs.lastUpdated > HUBS_REFRESH_INTERVAL: + util.DEBUG_LOG('Section is stale: REFRESHING - update: {0}'.format(update)) + self.cleanTasks() + if not update: + if section.key in self.sectionHubs: + self.sectionHubs[section.key] = None + self.tasks.append(SectionHubsTask().setup(section, self.sectionHubsCallback)) + backgroundthread.BGThreader.addTask(self.tasks[-1]) + return + + util.DEBUG_LOG('Showing hubs - Section: {0} - Update: {1}'.format(section.key, update)) + try: + hasContent = False + skip = {} + for hub in hubs: + identifier = hub.getCleanHubIdentifier() + + if identifier not in self.HUBMAP: + util.DEBUG_LOG('UNHANDLED - Hub: {0} [{1}]({2})'.format(hub.hubIdentifier, identifier, len(hub.items))) + continue + + skip[self.HUBMAP[identifier]['index']] = 1 + + if self.showHub(hub): + if hub.items: + hasContent = True + if self.HUBMAP[identifier].get('do_updates'): + self.updateHubs[identifier] = hub + + if not hasContent: + self.setBoolProperty('no.content', True) + + lastSkip = 0 + if skip: + lastSkip = min(skip.keys()) + + focus = None + if update: + for i, control in enumerate(self.hubControls): + if i in skip: + lastSkip = i + continue + if self.getFocusId() == control.getId(): + focus = lastSkip + control.reset() + + if focus is not None: + self.setFocusId(focus) + finally: + self.showBusy(False) + + def showHub(self, hub, items=None): + identifier = hub.getCleanHubIdentifier() + + if identifier in self.HUBMAP: + util.DEBUG_LOG('HUB: {0} [{1}]({2})'.format(hub.hubIdentifier, identifier, len(hub.items))) + self._showHub(hub, hubitems=items, **self.HUBMAP[identifier]) + return True + else: + util.DEBUG_LOG('UNHANDLED - Hub: {0} [{1}]({1})'.format(hub.hubIdentifier, identifier, len(hub.items))) + return + + def createGrandparentedListItem(self, obj, thumb_w, thumb_h, with_grandparent_title=False): + if with_grandparent_title and obj.get('grandparentTitle') and obj.title: + title = u'{0} - {1}'.format(obj.grandparentTitle, obj.title) + else: + title = obj.get('grandparentTitle') or obj.get('parentTitle') or obj.title or '' + mli = kodigui.ManagedListItem(title, thumbnailImage=obj.defaultThumb.asTranscodedImageURL(thumb_w, thumb_h), data_source=obj) + return mli + + def createParentedListItem(self, obj, thumb_w, thumb_h, with_parent_title=False): + if with_parent_title and obj.parentTitle and obj.title: + title = u'{0} - {1}'.format(obj.parentTitle, obj.title) + else: + title = obj.parentTitle or obj.title or '' + mli = kodigui.ManagedListItem(title, thumbnailImage=obj.defaultThumb.asTranscodedImageURL(thumb_w, thumb_h), data_source=obj) + return mli + + def createSimpleListItem(self, obj, thumb_w, thumb_h): + mli = kodigui.ManagedListItem(obj.title or '', thumbnailImage=obj.defaultThumb.asTranscodedImageURL(thumb_w, thumb_h), data_source=obj) + return mli + + def createEpisodeListItem(self, obj, wide=False): + mli = self.createGrandparentedListItem(obj, *self.THUMB_POSTER_DIM) + if obj.index: + subtitle = u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), obj.parentIndex, T(32311, 'E'), obj.index) + else: + subtitle = obj.originallyAvailableAt.asDatetime('%m/%d/%y') + + if wide: + mli.setLabel2(u'{0} - {1}'.format(util.shortenText(obj.title, 35), subtitle)) + else: + mli.setLabel2(subtitle) + + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/show.png') + if not obj.isWatched: + mli.setProperty('unwatched', '1') + return mli + + def createSeasonListItem(self, obj, wide=False): + mli = self.createParentedListItem(obj, *self.THUMB_POSTER_DIM) + # mli.setLabel2('Season {0}'.format(obj.index)) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/show.png') + if not obj.isWatched: + mli.setProperty('unwatched.count', str(obj.unViewedLeafCount)) + return mli + + def createMovieListItem(self, obj, wide=False): + mli = kodigui.ManagedListItem(obj.defaultTitle, obj.year, thumbnailImage=obj.defaultThumb.asTranscodedImageURL(*self.THUMB_POSTER_DIM), data_source=obj) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/movie.png') + if not obj.isWatched: + mli.setProperty('unwatched', '1') + return mli + + def createShowListItem(self, obj, wide=False): + mli = self.createSimpleListItem(obj, *self.THUMB_POSTER_DIM) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/show.png') + if not obj.isWatched: + mli.setProperty('unwatched.count', str(obj.unViewedLeafCount)) + return mli + + def createAlbumListItem(self, obj, wide=False): + mli = self.createParentedListItem(obj, *self.THUMB_SQUARE_DIM) + mli.setLabel2(obj.title) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/music.png') + return mli + + def createTrackListItem(self, obj, wide=False): + mli = self.createGrandparentedListItem(obj, *self.THUMB_SQUARE_DIM) + mli.setLabel2(obj.title) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/music.png') + return mli + + def createPhotoListItem(self, obj, wide=False): + mli = self.createSimpleListItem(obj, *self.THUMB_SQUARE_DIM) + if obj.type == 'photo': + mli.setLabel2(obj.originallyAvailableAt.asDatetime('%d %B %Y')) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/photo.png') + return mli + + def createClipListItem(self, obj, wide=False): + mli = self.createGrandparentedListItem(obj, *self.THUMB_AR16X9_DIM, with_grandparent_title=True) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/movie16x9.png') + return mli + + def createArtistListItem(self, obj, wide=False): + mli = self.createSimpleListItem(obj, *self.THUMB_SQUARE_DIM) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/music.png') + return mli + + def createPlaylistListItem(self, obj, wide=False): + if obj.playlistType == 'audio': + w, h = self.THUMB_SQUARE_DIM + thumb = obj.buildComposite(width=w, height=h, media='thumb') + else: + w, h = self.THUMB_AR16X9_DIM + thumb = obj.buildComposite(width=w, height=h, media='art') + + mli = kodigui.ManagedListItem( + obj.title or '', + util.durationToText(obj.duration.asInt()), + # thumbnailImage=obj.composite.asTranscodedImageURL(*self.THUMB_DIMS[obj.playlistType]['item.thumb']), + thumbnailImage=thumb, + data_source=obj + ) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/{0}.png'.format(obj.playlistType == 'audio' and 'music' or 'movie')) + return mli + + def unhandledHub(self, self2, obj, wide=False): + util.DEBUG_LOG('Unhandled Hub item: {0}'.format(obj.type)) + + CREATE_LI_MAP = { + 'episode': createEpisodeListItem, + 'season': createSeasonListItem, + 'movie': createMovieListItem, + 'show': createShowListItem, + 'album': createAlbumListItem, + 'track': createTrackListItem, + 'photo': createPhotoListItem, + 'photodirectory': createPhotoListItem, + 'clip': createClipListItem, + 'artist': createArtistListItem, + 'playlist': createPlaylistListItem + } + + def createListItem(self, obj, wide=False): + return self.CREATE_LI_MAP.get(obj.type, self.unhandledHub)(self, obj, wide) + + def clearHubs(self): + for control in self.hubControls: + control.reset() + + def _showHub(self, hub, hubitems=None, index=None, with_progress=False, with_art=False, ar16x9=False, + text2lines=False, **kwargs): + control = self.hubControls[index] + control.dataSource = hub + + if not hub.items and not hubitems: + control.reset() + return + + if not hubitems: + hub.reset() + + self.setProperty('hub.4{0:02d}'.format(index), hub.title or kwargs.get('title')) + self.setProperty('hub.text2lines.4{0:02d}'.format(index), text2lines and '1' or '') + + items = [] + + for obj in hubitems or hub.items: + if not self.backgroundSet: + if self.updateBackgroundFrom(obj): + self.backgroundSet = True + mli = self.createListItem(obj, wide=with_art) + if mli: + items.append(mli) + + if with_progress: + for mli in items: + mli.setProperty('progress', util.getProgressImage(mli.dataSource)) + if with_art: + for mli in items: + thumb = (util.advancedSettings.continueUseThumb + and mli.dataSource.type == 'episode' + and mli.dataSource.thumb + ) \ + or mli.dataSource.art + mli.setThumbnailImage(thumb.asTranscodedImageURL(*self.THUMB_AR16X9_DIM)) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/movie16x9.png') + if ar16x9: + for mli in items: + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/movie16x9.png') + + if hub.more.asBool(): + end = kodigui.ManagedListItem('') + end.setBoolProperty('is.end', True) + items.append(end) + + if hubitems: + end = control.size() - 1 + control.replaceItem(end, items[0]) + control.addItems(items[1:]) + control.selectItem(end) + else: + control.replaceItems(items) + + def updateListItem(self, mli): + if not mli or not mli.dataSource: # May have become invalid + return + + obj = mli.dataSource + if obj.type in ('episode', 'movie'): + mli.setProperty('unwatched', not obj.isWatched and '1' or '') + elif obj.type in ('season', 'show', 'album'): + if obj.isWatched: + mli.setProperty('unwatched.count', '') + else: + mli.setProperty('unwatched.count', str(obj.unViewedLeafCount)) + + def sectionClicked(self): + item = self.sectionList.getSelectedItem() + if not item: + return + + section = item.dataSource + + if section.type in ('show', 'movie', 'artist', 'photo'): + self.processCommand(opener.sectionClicked(section)) + elif section.type in ('playlists',): + self.processCommand(opener.handleOpen(playlists.PlaylistsWindow)) + + def onNewServer(self, **kwargs): + self.showServers(from_refresh=True) + + def onRemoveServer(self, **kwargs): + self.onNewServer() + + def onReachableServer(self, server=None, **kwargs): + for mli in self.serverList: + if mli.uuid == server.uuid: + mli.unHookSignals() + mli.dataSource = server + mli.hookSignals() + mli.onUpdate() + return + else: + self.onNewServer() + + def onSelectedServerChange(self, **kwargs): + if self.serverRefresh(): + self.setFocusId(self.SECTION_LIST_ID) + self.changingServer = False + + def showServers(self, from_refresh=False, mouse=False): + with self.lock: + selection = None + if from_refresh: + mli = self.serverList.getSelectedItem() + if mli: + selection = mli.uuid + + servers = sorted( + plexapp.SERVERMANAGER.getServers(), + key=lambda x: (x.owned and '0' or '1') + x.name.lower() + ) + + items = [] + for s in servers: + item = ServerListItem(s.name, not s.owned and s.owner or '', data_source=s) + item.uuid = s.uuid + item.onUpdate() + item.setProperty('current', plexapp.SERVERMANAGER.selectedServer.uuid == s.uuid and '1' or '') + items.append(item) + + if len(items) > 1: + items[0].setProperty('first', '1') + elif items: + items[0].setProperty('only', '1') + + self.serverList.replaceItems(items) + + self.getControl(800).setHeight((min(len(items), 9) * 100) + 80) + + for item in items: + if item.dataSource != kodigui.DUMMY_DATA_SOURCE: + item.hookSignals() + + if selection: + for mli in self.serverList: + if mli.uuid == selection: + self.serverList.selectItem(mli.pos()) + + if not from_refresh and items and not mouse: + self.setFocusId(self.SERVER_LIST_ID) + + if not from_refresh: + plexapp.refreshResources() + + def selectServer(self): + if self._shuttingDown: + return + + mli = self.serverList.getSelectedItem() + if not mli: + return + + self.changingServer = True + + # this is broken + with busy.BusySignalContext(plexapp.util.APP, "change:selectedServer") as bc: + self.setFocusId(self.SECTION_LIST_ID) + + server = mli.dataSource + + # fixme: this might still trigger a dialog, re-triggering the previously opened windows + if not self._shuttingDown and not server.isReachable(): + if server.pendingReachabilityRequests > 0: + util.messageDialog(T(32339, 'Server is not accessible'), T(32340, 'Connection tests are in ' + 'progress. Please wait.')) + else: + util.messageDialog( + T(32339, 'Server is not accessible'), T(32341, 'Server is not accessible. Please sign into ' + 'your server and check your connection.') + ) + bc.ignoreSignal = True + return + + changed = plexapp.SERVERMANAGER.setSelectedServer(server, force=True) + if not changed: + bc.ignoreSignal = True + self.changingServer = False + + def showUserMenu(self, mouse=False): + items = [] + if plexapp.ACCOUNT.isSignedIn: + if len(plexapp.ACCOUNT.homeUsers) > 1: + items.append(kodigui.ManagedListItem(T(32342, 'Switch User'), data_source='switch')) + items.append(kodigui.ManagedListItem(T(32343, 'Settings'), data_source='settings')) + if plexapp.ACCOUNT.isSignedIn: + items.append(kodigui.ManagedListItem(T(32344, 'Sign Out'), data_source='signout')) + elif plexapp.ACCOUNT.isOffline: + items.append(kodigui.ManagedListItem(T(32459, 'Offline Mode'), data_source='go_online')) + else: + items.append(kodigui.ManagedListItem(T(32460, 'Sign In'), data_source='signin')) + + if len(items) > 1: + items[0].setProperty('first', '1') + items[-1].setProperty('last', '1') + else: + items[0].setProperty('only', '1') + + self.userList.reset() + self.userList.addItems(items) + + self.getControl(801).setHeight((len(items) * 66) + 80) + + if not mouse: + self.setFocusId(self.USER_LIST_ID) + + def doUserOption(self): + mli = self.userList.getSelectedItem() + if not mli: + return + + option = mli.dataSource + + self.setFocusId(self.USER_BUTTON_ID) + + if option == 'settings': + from . import settings + settings.openWindow() + elif option == 'go_online': + plexapp.ACCOUNT.refreshAccount() + else: + self.closeOption = option + self.doClose() + + def showAudioPlayer(self): + from . import musicplayer + self.processCommand(opener.handleOpen(musicplayer.MusicPlayerWindow)) + + def finished(self): + if self.tasks: + for task in self.tasks: + task.cancel() diff --git a/script.plexmod/lib/windows/info.py b/script.plexmod/lib/windows/info.py new file mode 100644 index 000000000..4c3374cd2 --- /dev/null +++ b/script.plexmod/lib/windows/info.py @@ -0,0 +1,149 @@ +from __future__ import absolute_import +from . import kodigui +from . import windowutils +from lib import util +from plexnet.video import Episode, Movie, Clip + +import os + + +def split2len(s, n): + def _f(s, n): + while s: + yield s[:n] + s = s[n:] + return list(_f(s, n)) + + +class InfoWindow(kodigui.ControlledWindow, windowutils.UtilMixin): + xmlFile = 'script-plex-info.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + PLAYER_STATUS_BUTTON_ID = 204 + + THUMB_DIM_POSTER = util.scaleResolution(519, 469) + THUMB_DIM_SQUARE = util.scaleResolution(519, 519) + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + self.title = kwargs.get('title') + self.subTitle = kwargs.get('sub_title') + self.thumb = kwargs.get('thumb') + self.thumbFallback = kwargs.get('thumb_fallback') + self.info = kwargs.get('info') + self.background = kwargs.get('background') + self.isSquare = kwargs.get('is_square') + self.is16x9 = kwargs.get('is_16x9') + self.isPoster = not (self.isSquare or self.is16x9) + self.thumbDim = self.isSquare and self.THUMB_DIM_SQUARE or self.THUMB_DIM_POSTER + self.video = kwargs.get('video') + + def getVideoInfo(self): + """ + Append media/part/stream info to summary + """ + if not isinstance(self.video, (Episode, Movie, Clip)): + return self.info + + summary = [self.info] + medias = self.video.media() + mediaCount = len(medias) + onlyOneMedia = mediaCount == 1 + partCount = sum(len(m.parts) for m in medias) + pcInfo = [] + if not onlyOneMedia: + pcInfo.append("Files: {}".format(mediaCount)) + if partCount > 1: + pcInfo.append("Parts: {}".format(partCount)) + pcInfoStr = ", ".join(pcInfo) + + addMedia = ["\n\n\n\nMedia{}\n".format(" ({})".format(pcInfoStr) if pcInfoStr else "")] + for media_ in medias: + if not media_.isAccessible(): + addMedia.append("Unavailable: {}\n\n".format(", ".join(os.path.basename(pf.file) for pf in media_.parts))) + continue + + for part in media_.parts: + if not part: + addMedia.append("Unavailable: {}".format(os.path.basename(part.file))) + continue + + addMedia.append("File: ") + splitFnAt = 74 + fnLen = len(os.path.basename(part.file)) + appended = False + for s in split2len(os.path.basename(part.file), splitFnAt): + if fnLen > splitFnAt and not appended: + addMedia.append("{}\n".format(s)) + appended = True + continue + addMedia.append("{}\n".format(s)) + addMedia.append("Duration: {}, Size: {}\n".format(util.durationToShortText(int(part.duration)), + util.simpleSize(int(part.size)))) + + subs = [] + subsOver = 0 + for stream in part.streams: + streamtype = stream.streamType.asInt() + # video + if streamtype == 1: + addMedia.append("Video: {}x{}, {} {}/{}bit/{}/{}@{} kBit, {} fps\n".format( + stream.width, stream.height, stream.videoCodecRendering, stream.codec.upper(), + stream.bitDepth, stream.chromaSubsampling, stream.colorPrimaries, stream.bitrate, + stream.frameRate)) + # audio + elif streamtype == 2: + addMedia.append("Audio: {}{}, {}/{}ch@{} kBit, {} Hz\n".format( + stream.language, + " (default)" if stream.default else "", + stream.codec.upper(), + stream.channels, stream.bitrate, + stream.samplingRate)) + # subtitle + elif streamtype == 3: + if len(subs) > 4: + subsOver += 1 + continue + subs.append("{} ({})".format(stream.language, stream.codec.upper())) + + if subs: + addMedia.append("Subtitles: {}{}\n".format(", ".join(subs), + subsOver and " (+{})".format(subsOver) or '')) + if not onlyOneMedia: + addMedia.append("--------------\n") + + chapters = [] + chOver = 0 + for index, chapter in enumerate(self.video.chapters): + if len(chapters) > 4: + chOver += 1 + continue + chapters.append(chapter.tag or "Chapter #{}".format(str(index+1))) + + if chapters: + addMedia.append("Chapters: {}{}\n".format(", ".join(chapters), chOver and " (+{})".format(chOver) or '')) + + if self.video.markers: + addMedia.append("Markers: {}".format(", ".join(name for off, name in sorted( + (int(marker.startTimeOffset), marker.type) for marker in self.video.markers)))) + + return "".join(summary + addMedia) + + def onFirstInit(self): + self.setProperty('is.poster', self.isPoster and '1' or '') + self.setProperty('is.square', self.isSquare and '1' or '') + self.setProperty('is.16x9', self.is16x9 and '1' or '') + self.setProperty('title.main', self.title) + self.setProperty('title.sub', self.subTitle) + self.setProperty('thumb.fallback', self.thumbFallback) + self.setProperty('thumb', self.thumb.asTranscodedImageURL(*self.thumbDim)) + self.setProperty('info', self.getVideoInfo()) + self.setProperty('background', self.background) + + def onClick(self, controlID): + if controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() diff --git a/script.plexmod/lib/windows/kodigui.py b/script.plexmod/lib/windows/kodigui.py new file mode 100644 index 000000000..379633920 --- /dev/null +++ b/script.plexmod/lib/windows/kodigui.py @@ -0,0 +1,1105 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui +import time +import threading +import traceback +from six.moves import range +from six.moves import zip +from .. import util + +MONITOR = None + + +class BaseFunctions: + xmlFile = '' + path = '' + theme = '' + res = '720p' + width = 1280 + height = 720 + + usesGenerate = False + lastWinID = None + lastDialogID = None + + def __init__(self): + self.isOpen = True + + def onWindowFocus(self): + # Not automatically called. Can be used by an external window manager + pass + + def onClosed(self): + pass + + @classmethod + def open(cls, **kwargs): + window = cls(cls.xmlFile, cls.path, cls.theme, cls.res, **kwargs) + window.modal() + return window + + @classmethod + def create(cls, show=True, **kwargs): + window = cls(cls.xmlFile, cls.path, cls.theme, cls.res, **kwargs) + if show: + window.show() + if xbmcgui.getCurrentWindowId() < 13000: + window.isOpen = False + return window + + window.isOpen = xbmcgui.getCurrentWindowId() >= 13000 + return window + + def modal(self): + self.isOpen = True + try: + self.doModal() + except SystemExit: + pass + self.onClosed() + self.isOpen = False + + def activate(self): + if not self._winID: + self._winID = xbmcgui.getCurrentWindowId() + xbmc.executebuiltin('ReplaceWindow({0})'.format(self._winID)) + + def mouseXTrans(self, val): + return int((val / self.getWidth()) * self.width) + + def mouseYTrans(self, val): + return int((val / self.getHeight()) * self.height) + + def closing(self): + return self._closing + + @classmethod + def generate(self): + return None + + def setProperties(self, prop_list, val_list_or_val): + if isinstance(val_list_or_val, list) or isinstance(val_list_or_val, tuple): + val_list = val_list_or_val + else: + val_list = [val_list_or_val] * len(prop_list) + + for prop, val in zip(prop_list, val_list): + self.setProperty(prop, val) + + def propertyContext(self, prop, val='1'): + return WindowProperty(self, prop, val) + + def setBoolProperty(self, key, boolean): + self.setProperty(key, boolean and '1' or '') + + +LAST_BG_URL = None + + +class BaseWindow(xbmcgui.WindowXML, BaseFunctions): + def __init__(self, *args, **kwargs): + BaseFunctions.__init__(self) + self._closing = False + self._winID = None + self.started = False + self.finishedInit = False + + def onInit(self): + global LAST_BG_URL + self._winID = xbmcgui.getCurrentWindowId() + BaseFunctions.lastWinID = self._winID + self.setProperty('use_solid_background', util.hasCustomBGColour and '1' or '') + if util.hasCustomBGColour: + bgColour = util.advancedSettings.backgroundColour if util.advancedSettings.backgroundColour != "-" \ + else "ff000000" + self.setProperty('background_colour', "0x%s" % bgColour.lower()) + self.setProperty('background_colour_opaque', "0x%s" % bgColour.lower()) + else: + # set background color to 0 to avoid kodi UI BG clearing, improves performance + if util.advancedSettings.dbgCrossfade: + self.setProperty('background_colour', "0x00000000") + else: + self.setProperty('background_colour', "0xff111111") + self.setProperty('background_colour_opaque', "0xff111111") + + self.setBoolProperty('use_bg_fallback', util.advancedSettings.useBgFallback) + + try: + if self.started: + self.onReInit() + else: + self.started = True + if LAST_BG_URL: + self.windowSetBackground(LAST_BG_URL) + self.onFirstInit() + self.finishedInit = True + except util.NoDataException: + self.exitCommand = "NODATA" + self.doClose() + + def onFirstInit(self): + pass + + def onReInit(self): + pass + + def setProperty(self, key, value): + if self._closing: + return + + if not self._winID: + self._winID = xbmcgui.getCurrentWindowId() + + try: + xbmcgui.Window(self._winID).setProperty(key, value) + xbmcgui.WindowXML.setProperty(self, key, value) + except RuntimeError: + xbmc.log('kodigui.BaseWindow.setProperty: Missing window', xbmc.LOGDEBUG) + + def updateBackgroundFrom(self, ds): + if util.advancedSettings.dynamicBackgrounds: + return self.windowSetBackground(util.backgroundFromArt(ds.art, width=self.width, + height=self.height)) + + def windowSetBackground(self, value): + if not util.advancedSettings.dbgCrossfade: + if not value: + return + self.setProperty("background_static", value) + return value + + global LAST_BG_URL + + if not value: + if LAST_BG_URL: + self.setProperty("background_static", LAST_BG_URL) + return LAST_BG_URL + return + + cur1 = self.getProperty('background') + if not cur1: + self.setProperty("background_static", value) + self.setProperty("background", value) + + elif LAST_BG_URL != value: + self.setProperty("background_static", LAST_BG_URL) + self.setProperty("background", value) + + LAST_BG_URL = value + return value + + def doClose(self): + if not self.isOpen: + return + self._closing = True + self.isOpen = False + self.close() + + def show(self): + self._closing = False + #self.isOpen = True + xbmcgui.WindowXML.show(self) + self.isOpen = xbmcgui.getCurrentWindowId() >= 13000 + + def onClosed(self): + pass + + +class BaseDialog(xbmcgui.WindowXMLDialog, BaseFunctions): + def __init__(self, *args, **kwargs): + BaseFunctions.__init__(self) + self._closing = False + self._winID = '' + self.started = False + + def onInit(self): + self._winID = xbmcgui.getCurrentWindowDialogId() + BaseFunctions.lastDialogID = self._winID + if self.started: + self.onReInit() + else: + self.started = True + self.onFirstInit() + + def onFirstInit(self): + pass + + def onReInit(self): + pass + + def setProperty(self, key, value): + if self._closing: + return + + if not self._winID: + self._winID = xbmcgui.getCurrentWindowId() + + try: + xbmcgui.Window(self._winID).setProperty(key, value) + xbmcgui.WindowXMLDialog.setProperty(self, key, value) + except RuntimeError: + xbmc.log('kodigui.BaseDialog.setProperty: Missing window', xbmc.LOGDEBUG) + + def doClose(self): + self._closing = True + self.close() + self.isOpen = False + + def show(self): + self._closing = False + xbmcgui.WindowXMLDialog.show(self) + self.isOpen = True + + def onClosed(self): + pass + + +class ControlledBase: + def doModal(self): + self.show() + self.wait() + + def wait(self): + while not self._closing and not MONITOR.waitForAbort(0.1): + pass + + def close(self): + self._closing = True + + +class ControlledWindow(ControlledBase, BaseWindow): + def onAction(self, action): + try: + if action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK): + self.doClose() + return + except: + traceback.print_exc() + + BaseWindow.onAction(self, action) + + +class ControlledDialog(ControlledBase, BaseDialog): + def onAction(self, action): + try: + if action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK): + self.doClose() + return + except: + traceback.print_exc() + + BaseDialog.onAction(self, action) + + +DUMMY_LIST_ITEM = xbmcgui.ListItem() + + +class DummyDataSource(object): + def __nonzero__(self): + return False + + __bool__ = __nonzero__ + + def exists(self): + return False + + +DUMMY_DATA_SOURCE = DummyDataSource() + + +class ManagedListItem(object): + def __init__(self, label='', label2='', iconImage='', thumbnailImage='', path='', data_source=None, properties=None): + self._listItem = xbmcgui.ListItem(label, label2, path=path) + self._listItem.setArt({"thumb": thumbnailImage, "icon": iconImage}) + self.dataSource = data_source + self.properties = {} + self.label = label + self.label2 = label2 + self.iconImage = iconImage + self.thumbnailImage = thumbnailImage + self.path = path + self._ID = None + self._manager = None + self._valid = True + if properties: + for k, v in properties.items(): + self.setProperty(k, v) + + def __nonzero__(self): + return self._valid + + @property + def listItem(self): + if not self._listItem: + if not self._manager: + return None + + try: + self._listItem = self._manager.getListItemFromManagedItem(self) + except RuntimeError: + return None + + return self._listItem + + def invalidate(self): + self._valid = False + self._listItem = DUMMY_LIST_ITEM + self.dataSource = DUMMY_DATA_SOURCE + + def _takeListItem(self, manager, lid): + self._manager = manager + self._ID = lid + self._listItem.setProperty('__ID__', lid) + li = self._listItem + self._listItem = None + self._manager._properties.update(self.properties) + return li + + def _updateListItem(self): + self.listItem.setProperty('__ID__', self._ID) + self.listItem.setLabel(self.label) + self.listItem.setLabel2(self.label2) + self.listItem.setArt({"thumb": self.thumbnailImage, "icon": self.iconImage}) + self.listItem.setPath(self.path) + for k in self._manager._properties.keys(): + self.listItem.setProperty(k, self.properties.get(k) or '') + + def clear(self): + self.label = '' + self.label2 = '' + self.iconImage = '' + self.thumbnailImage = '' + self.path = '' + for k in self.properties: + self.properties[k] = '' + self._updateListItem() + + def pos(self): + if not self._manager: + return None + return self._manager.getManagedItemPosition(self) + + def addContextMenuItems(self, items, replaceItems=False): + self.listItem.addContextMenuItems(items, replaceItems) + + def addStreamInfo(self, stype, values): + self.listItem.addStreamInfo(stype, values) + + def getLabel(self): + return self.label + + def getLabel2(self): + return self.label2 + + def getProperty(self, key): + return self.properties.get(key, '') + + def getdescription(self): + return self.listItem.getdescription() + + def getduration(self): + return self.listItem.getduration() + + def getfilename(self): + return self.listItem.getfilename() + + def isSelected(self): + return self.listItem.isSelected() + + def select(self, selected): + return self.listItem.select(selected) + + def setArt(self, values): + return self.listItem.setArt(values) + + def setIconImage(self, icon): + self.iconImage = icon + return self.listItem.setArt({"icon": self.iconImage}) + + def setInfo(self, itype, infoLabels): + return self.listItem.setInfo(itype, infoLabels) + + def setLabel(self, label): + self.label = label + return self.listItem.setLabel(label) + + def setLabel2(self, label): + self.label2 = label + return self.listItem.setLabel2(label) + + def setMimeType(self, mimetype): + return self.listItem.setMimeType(mimetype) + + def setPath(self, path): + self.path = path + return self.listItem.setPath(path) + + def setProperty(self, key, value): + if self._manager: + self._manager._properties[key] = 1 + self.properties[key] = value + self.listItem.setProperty(key, value) + return self + + def setBoolProperty(self, key, boolean): + return self.setProperty(key, boolean and '1' or '') + + def setSubtitles(self, subtitles): + return self.listItem.setSubtitles(subtitles) # List of strings - HELIX + + def setThumbnailImage(self, thumb): + self.thumbnailImage = thumb + return self.listItem.setArt({"thumb": self.thumbnailImage}) + + def onDestroy(self): + pass + + +class ManagedControlList(object): + def __init__(self, window, control_id, max_view_index, data_source=None): + self.controlID = control_id + self.control = window.getControl(control_id) + self.items = [] + self._sortKey = None + self._idCounter = 0 + self._maxViewIndex = max_view_index + self._properties = {} + self.dataSource = data_source + + def __getattr__(self, name): + return getattr(self.control, name) + + def __getitem__(self, idx): + if isinstance(idx, slice): + return self.items[idx] + else: + return self.getListItem(idx) + + def __iter__(self): + for i in self.items: + yield i + + def __len__(self): + return self.size() + + def _updateItems(self, bottom=None, top=None): + if bottom is None: + bottom = 0 + top = self.size() + + try: + for idx in range(bottom, top): + try: + li = self.control.getListItem(idx) + except RuntimeError: + continue + + mli = self.items[idx] + self._properties.update(mli.properties) + mli._manager = self + mli._listItem = li + mli._updateListItem() + mli.setProperty('index', str(idx)) + except RuntimeError: + #xbmc.log('kodigui.ManagedControlList._updateItems: Runtime error', xbmc.LOGINFO) + util.ERROR('kodigui.ManagedControlList._updateItems: Runtime error') + return False + + return True + + def _nextID(self): + self._idCounter += 1 + return str(self._idCounter) + + def reInit(self, window, control_id): + self.controlID = control_id + self.control = window.getControl(control_id) + self.control.addItems([i._takeListItem(self, self._nextID()) for i in self.items]) + + def setSort(self, sort): + self._sortKey = sort + + def addItem(self, managed_item): + self.items.append(managed_item) + self.control.addItem(managed_item._takeListItem(self, self._nextID())) + + def addItems(self, managed_items): + self.items += managed_items + self.control.addItems([i._takeListItem(self, self._nextID()) for i in managed_items]) + + def replaceItem(self, pos, mli): + self[pos].onDestroy() + self[pos].invalidate() + self.items[pos] = mli + li = self.control.getListItem(pos) + mli._manager = self + mli._listItem = li + mli._updateListItem() + + def replaceItems(self, managed_items): + if not self.items: + self.addItems(managed_items) + return True + + oldSize = self.size() + + for i in self.items: + i.onDestroy() + i.invalidate() + + self.items = managed_items + size = self.size() + if size != oldSize: + pos = self.getSelectedPosition() + + if size > oldSize: + for i in range(0, size - oldSize): + self.control.addItem(xbmcgui.ListItem()) + elif size < oldSize: + diff = oldSize - size + idx = oldSize - 1 + while diff: + self.control.removeItem(idx) + idx -= 1 + diff -= 1 + + if self.positionIsValid(pos): + self.selectItem(pos) + elif pos >= size: + self.selectItem(size - 1) + + return self._updateItems(0, self.size()) + + def getListItem(self, pos): + li = self.control.getListItem(pos) + mli = self.items[pos] + mli._listItem = li + return mli + + def getListItemByDataSource(self, data_source): + for mli in self: + if data_source == mli.dataSource: + return mli + return None + + def getSelectedItem(self): + pos = self.control.getSelectedPosition() + if not self.positionIsValid(pos): + pos = self.size() - 1 + + if pos < 0: + return None + return self.getListItem(pos) + + def setSelectedItemByPos(self, pos): + if self.positionIsValid(pos): + self.control.selectItem(pos) + + def removeItem(self, index): + old = self.items.pop(index) + old.onDestroy() + old.invalidate() + + self.control.removeItem(index) + top = self.control.size() - 1 + if top < 0: + return + if top < index: + index = top + self.control.selectItem(index) + + def removeManagedItem(self, mli): + self.removeItem(mli.pos()) + + def insertItem(self, index, managed_item): + pos = self.getSelectedPosition() + 1 + + if index >= self.size() or index < 0: + self.addItem(managed_item) + else: + self.items.insert(index, managed_item) + self.control.addItem(managed_item._takeListItem(self, self._nextID())) + self._updateItems(index, self.size()) + + if self.positionIsValid(pos): + self.selectItem(pos) + + def moveItem(self, mli, dest_idx): + source_idx = mli.pos() + if source_idx < dest_idx: + rstart = source_idx + rend = dest_idx + 1 + # dest_idx-=1 + else: + rstart = dest_idx + rend = source_idx + 1 + mli = self.items.pop(source_idx) + self.items.insert(dest_idx, mli) + + self._updateItems(rstart, rend) + + def swapItems(self, pos1, pos2): + if not self.positionIsValid(pos1) or not self.positionIsValid(pos2): + return False + + item1 = self.items[pos1] + item2 = self.items[pos2] + li1 = item1._listItem + li2 = item2._listItem + item1._listItem = li2 + item2._listItem = li1 + + item1._updateListItem() + item2._updateListItem() + self.items[pos1] = item2 + self.items[pos2] = item1 + + return True + + def shiftView(self, shift, hold_selected=False): + if not self._maxViewIndex: + return + selected = self.getSelectedItem() + selectedPos = selected.pos() + viewPos = self.getViewPosition() + + if shift > 0: + pushPos = selectedPos + (self._maxViewIndex - viewPos) + shift + if pushPos >= self.size(): + pushPos = self.size() - 1 + self.selectItem(pushPos) + newViewPos = self._maxViewIndex + elif shift < 0: + pushPos = (selectedPos - viewPos) + shift + if pushPos < 0: + pushPos = 0 + self.selectItem(pushPos) + newViewPos = 0 + + if hold_selected: + self.selectItem(selected.pos()) + else: + diff = newViewPos - viewPos + fix = pushPos - diff + # print '{0} {1} {2}'.format(newViewPos, viewPos, fix) + if self.positionIsValid(fix): + self.selectItem(fix) + + def reset(self): + self.dataSource = None + for i in self.items: + i.onDestroy() + i.invalidate() + self.items = [] + self.control.reset() + + def size(self): + return len(self.items) + + def getViewPosition(self): + try: + return int(xbmc.getInfoLabel('Container({0}).Position'.format(self.controlID))) + except: + return 0 + + def getViewRange(self): + viewPosition = self.getViewPosition() + selected = self.getSelectedPosition() + return list(range(max(selected - viewPosition, 0), min(selected + (self._maxViewIndex - viewPosition) + 1, self.size() - 1))) + + def positionIsValid(self, pos): + return 0 <= pos < self.size() + + def sort(self, sort=None, reverse=False): + sort = sort or self._sortKey + + self.items.sort(key=sort, reverse=reverse) + + self._updateItems(0, self.size()) + + def reverse(self): + self.items.reverse() + self._updateItems(0, self.size()) + + def getManagedItemPosition(self, mli): + return self.items.index(mli) + + def isLastItem(self, mli=None): + return self.getManagedItemPosition(mli or self.getSelectedItem()) + 1 == len(self) + + def getListItemFromManagedItem(self, mli): + pos = self.items.index(mli) + return self.control.getListItem(pos) + + def topHasFocus(self): + return self.getSelectedPosition() == 0 + + def bottomHasFocus(self): + return self.getSelectedPosition() == self.size() - 1 + + def invalidate(self): + for item in self.items: + item._listItem = DUMMY_LIST_ITEM + + def newControl(self, window=None, control_id=None): + self.controlID = control_id or self.controlID + self.control = window.getControl(self.controlID) + self.control.addItems([xbmcgui.ListItem() for i in range(self.size())]) + self._updateItems() + + +class _MWBackground(ControlledWindow): + def __init__(self, *args, **kwargs): + self._multiWindow = kwargs.get('multi_window') + self.started = False + BaseWindow.__init__(self, *args, **kwargs) + + def onInit(self): + if self.started: + return + self.started = True + self._multiWindow._open() + self.close() + + +class MultiWindow(object): + def __init__(self, windows=None, default_window=None, **kwargs): + self._windows = windows + self._next = default_window or self._windows[0] + self._properties = {} + self._current = None + self._allClosed = False + self.exitCommand = None + + def __getattr__(self, name): + return getattr(self._current, name) + + def setWindows(self, windows): + self._windows = windows + + def setDefault(self, default): + self._next = default or self._windows[0] + + def windowIndex(self, window): + if hasattr(window, 'MULTI_WINDOW_ID'): + for i, w in enumerate(self._windows): + if window.MULTI_WINDOW_ID == w.MULTI_WINDOW_ID: + return i + return 0 + else: + return self._windows.index(window.__class__) + + def nextWindow(self, window=None): + if window is False: + window = self._windows[self.windowIndex(self._current)] + + if window: + if window.__class__ == self._current.__class__: + return None + else: + idx = self.windowIndex(self._current) + idx += 1 + if idx >= len(self._windows): + idx = 0 + window = self._windows[idx] + + self._next = window + self._current.doClose() + return self._next + + def _setupCurrent(self, cls): + self._current = cls(cls.xmlFile, cls.path, cls.theme, cls.res) + self._current.onFirstInit = self._onFirstInit + self._current.onReInit = self.onReInit + self._current.onClick = self.onClick + self._current.onFocus = self.onFocus + + self._currentOnAction = self._current.onAction + self._current.onAction = self.onAction + + @classmethod + def open(cls, **kwargs): + mw = cls(**kwargs) + b = _MWBackground(mw.bgXML, mw.path, mw.theme, mw.res, multi_window=mw) + b.modal() + del b + import gc + gc.collect(2) + return mw + + def _open(self): + while not MONITOR.abortRequested() and not self._allClosed: + self._setupCurrent(self._next) + self._current.modal() + + self._current.doClose() + del self._current + del self._next + del self._currentOnAction + + def setProperty(self, key, value): + self._properties[key] = value + self._current.setProperty(key, value) + + def _onFirstInit(self): + for k, v in self._properties.items(): + self._current.setProperty(k, v) + self.onFirstInit() + + def doClose(self): + self._allClosed = True + self._current.doClose() + + def onFirstInit(self): + pass + + def onReInit(self): + pass + + def onAction(self, action): + if action == xbmcgui.ACTION_PREVIOUS_MENU or action == xbmcgui.ACTION_NAV_BACK: + self.doClose() + self._currentOnAction(action) + + def onClick(self, controlID): + pass + + def onFocus(self, controlID): + pass + + +class SafeControlEdit(object): + CHARS_LOWER = 'abcdefghijklmnopqrstuvwxyz' + CHARS_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + CHARS_NUMBERS = '0123456789' + CURSOR = '[COLOR FFCC7B19]|[/COLOR]' + + def __init__(self, control_id, label_id, window, key_callback=None, grab_focus=False): + self.controlID = control_id + self.labelID = label_id + self._win = window + self._keyCallback = key_callback + self.grabFocus = grab_focus + self._text = '' + self._compatibleMode = False + self.setup() + + def setup(self): + self._labelControl = self._win.getControl(self.labelID) + self._winOnAction = self._win.onAction + self._win.onAction = self.onAction + self.updateLabel() + + def setCompatibleMode(self, on): + self._compatibleMode = on + + def onAction(self, action): + try: + controlID = self._win.getFocusId() + if controlID == self.controlID: + if self.processAction(action.getId()): + return + elif self.grabFocus: + if self.processOffControlAction(action.getButtonCode()): + self._win.setFocusId(self.controlID) + return + except: + traceback.print_exc() + + self._winOnAction(action) + + def processAction(self, action_id): + if not self._compatibleMode: + oldVal = self._text + self._text = self._win.getControl(self.controlID).getText() + + if self._keyCallback: + self._keyCallback(action_id, oldVal, self._text) + + self.updateLabel() + + return True + oldVal = self.getText() + + if 61793 <= action_id <= 61818: # Lowercase + self.processChar(self.CHARS_LOWER[action_id - 61793]) + elif 61761 <= action_id <= 61786: # Uppercase + self.processChar(self.CHARS_UPPER[action_id - 61761]) + elif 61744 <= action_id <= 61753: + self.processChar(self.CHARS_NUMBERS[action_id - 61744]) + elif action_id == 61728: # Space + self.processChar(' ') + elif action_id == 61448: + self.delete() + else: + return False + + if self._keyCallback: + self._keyCallback(action_id, oldVal, self.getText()) + + return True + + def processOffControlAction(self, action_id): + oldVal = self.getText() if self._compatibleMode else self._text + if 61505 <= action_id <= 61530: # Lowercase + self.processChar(self.CHARS_LOWER[action_id - 61505]) + elif 192577 <= action_id <= 192602: # Uppercase + self.processChar(self.CHARS_UPPER[action_id - 192577]) + elif 61488 <= action_id <= 61497: + self.processChar(self.CHARS_NUMBERS[action_id - 61488]) + elif 61552 <= action_id <= 61561: + self.processChar(self.CHARS_NUMBERS[action_id - 61552]) + elif action_id == 61472: # Space + self.processChar(' ') + else: + return False + + if self._keyCallback: + self._keyCallback(action_id, oldVal, self.getText()) + + return True + + def _setText(self, text): + self._text = text + + if not self._compatibleMode: + self._win.getControl(self.controlID).setText(text) + self.updateLabel() + + def _getText(self): + if not self._compatibleMode and self._win.getFocusId() == self.controlID: + return self._win.getControl(self.controlID).getText() + else: + return self._text + + def updateLabel(self): + self._labelControl.setLabel(self._getText() + self.CURSOR) + + def processChar(self, char): + self._setText(self.getText() + char) + + def setText(self, text): + self._setText(text) + + def getText(self): + return self._getText() + + def append(self, text): + self._setText(self.getText() + text) + + def delete(self): + self._setText(self.getText()[:-1]) + + +class PropertyTimer(): + def __init__(self, window_id, timeout, property_, value='', init_value='1', addon_id=None, callback=None): + self._winID = window_id + self._timeout = timeout + self._property = property_ + self._value = value + self._initValue = init_value + self._endTime = 0 + self._thread = None + self._addonID = addon_id + self._closeWin = None + self._closed = False + self._callback = callback + + def _onTimeout(self): + self._endTime = 0 + xbmcgui.Window(self._winID).setProperty(self._property, self._value) + if self._addonID: + xbmcgui.Window(10000).setProperty('{0}.{1}'.format(self._addonID, self._property), self._value) + if self._closeWin: + self._closeWin.doClose() + if self._callback: + self._callback() + + def _wait(self): + while not MONITOR.abortRequested() and time.time() < self._endTime: + xbmc.sleep(100) + if MONITOR.abortRequested(): + return + if self._endTime == 0: + return + self._onTimeout() + + def _stopped(self): + return not self._thread or not self._thread.is_alive() + + def _reset(self): + self._endTime = time.time() + self._timeout + + def _start(self): + self.init(self._initValue) + self._thread = threading.Thread(target=self._wait) + self._thread.start() + + def stop(self, trigger=False): + self._endTime = trigger and 1 or 0 + if not self._stopped(): + self._thread.join() + + def close(self): + self._closed = True + self.stop() + + def init(self, val): + if val is False: + return + elif val is None: + val = self._initValue + + xbmcgui.Window(self._winID).setProperty(self._property, val) + if self._addonID: + xbmcgui.Window(10000).setProperty('{0}.{1}'.format(self._addonID, self._property), val) + + def reset(self, close_win=None, init=None): + self.init(init) + + if self._closed: + return + + if not self._timeout: + return + + self._closeWin = close_win + self._reset() + + if self._stopped: + self._start() + + +class WindowProperty(): + def __init__(self, win, prop, val='1', end=None): + self.win = win + self.prop = prop + self.val = val + self.end = end + self.old = self.win.getProperty(self.prop) + + def __enter__(self): + self.win.setProperty(self.prop, self.val) + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.win.setProperty(self.prop, self.end or self.old) + + +class GlobalProperty(): + def __init__(self, prop, val='1', end=None): + from kodi_six import xbmcaddon + self._addonID = xbmcaddon.Addon().getAddonInfo('id') + self.prop = prop + self.val = val + self.end = end + self.old = xbmc.getInfoLabel('Window(10000).Property({0}}.{1})'.format(self._addonID, prop)) + + def __enter__(self): + xbmcgui.Window(10000).setProperty('{0}.{1}'.format(self._addonID, self.prop), self.val) + return self + + def __exit__(self, exc_type, exc_value, traceback): + xbmcgui.Window(10000).setProperty('{0}.{1}'.format(self._addonID, self.prop), self.end or self.old) diff --git a/script.plexmod/lib/windows/library.py b/script.plexmod/lib/windows/library.py new file mode 100644 index 000000000..62245203a --- /dev/null +++ b/script.plexmod/lib/windows/library.py @@ -0,0 +1,1868 @@ +from __future__ import absolute_import +import os +import random +import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error +import json +import time +import threading + +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui + +from lib import util +from lib import backgroundthread +from lib import player + +from . import busy +from . import subitems +from . import preplay +from . import search +import plexnet +from . import dropdown +from . import opener +from . import windowutils + +from plexnet import playqueue + +from lib.util import T +import six +from six.moves import range + +CHUNK_SIZE = 200 +# CHUNK_SIZE = 30 + +KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + +MOVE_SET = frozenset( + ( + xbmcgui.ACTION_MOVE_LEFT, + xbmcgui.ACTION_MOVE_RIGHT, + xbmcgui.ACTION_MOVE_UP, + xbmcgui.ACTION_MOVE_DOWN, + xbmcgui.ACTION_MOUSE_MOVE, + xbmcgui.ACTION_PAGE_UP, + xbmcgui.ACTION_PAGE_DOWN, + xbmcgui.ACTION_FIRST_PAGE, + xbmcgui.ACTION_LAST_PAGE, + xbmcgui.ACTION_MOUSE_WHEEL_DOWN, + xbmcgui.ACTION_MOUSE_WHEEL_UP + ) +) + +THUMB_POSTER_DIM = util.scaleResolution(268, 397) +THUMB_AR16X9_DIM = util.scaleResolution(619, 348) +THUMB_SQUARE_DIM = util.scaleResolution(355, 355) +ART_AR16X9_DIM = util.scaleResolution(630, 355) + +TYPE_KEYS = { + 'episode': { + 'fallback': 'show', + 'thumb_dim': THUMB_POSTER_DIM, + }, + 'season': { + 'fallback': 'show', + 'thumb_dim': THUMB_POSTER_DIM + }, + 'movie': { + 'fallback': 'movie', + 'thumb_dim': THUMB_POSTER_DIM, + 'art_dim': ART_AR16X9_DIM + }, + 'show': { + 'fallback': 'show', + 'thumb_dim': THUMB_POSTER_DIM, + 'art_dim': ART_AR16X9_DIM + }, + 'collection': { + 'fallback': 'movie', + 'thumb_dim': THUMB_POSTER_DIM, + 'art_dim': ART_AR16X9_DIM + }, + 'album': { + 'fallback': 'music', + 'thumb_dim': THUMB_SQUARE_DIM + }, + 'artist': { + 'fallback': 'music', + 'thumb_dim': THUMB_SQUARE_DIM + }, + 'track': { + 'fallback': 'music', + 'thumb_dim': THUMB_SQUARE_DIM + }, + 'photo': { + 'fallback': 'photo', + 'thumb_dim': THUMB_SQUARE_DIM + }, + 'clip': { + 'fallback': 'movie16x9', + 'thumb_dim': THUMB_POSTER_DIM + }, +} + +TYPE_PLURAL = { + 'artist': T(32347, 'artists'), + 'album': T(32461, 'albums'), + 'movie': T(32348, 'Movies'), + 'photo': T(32349, 'photos'), + 'show': T(32350, 'Shows'), + 'episode': T(32458, 'Episodes'), + 'collection': T(32490, 'Collections'), + 'folder': T(32491, 'Folders'), +} + +SORT_KEYS = { + 'movie': { + 'titleSort': {'title': T(32357, 'By Name'), 'display': T(32358, 'Name'), 'defSortDesc': False}, + 'addedAt': {'title': T(32351, 'By Date Added'), 'display': T(32352, 'Date Added'), 'defSortDesc': True}, + 'originallyAvailableAt': {'title': T(32353, 'By Release Date'), 'display': T(32354, 'Release Date'), + 'defSortDesc': True}, + 'lastViewedAt': {'title': T(32355, 'By Date Viewed'), 'display': T(32356, 'Date Viewed'), 'defSortDesc': True}, + 'rating': {'title': T(33107, 'By Critic Rating'), 'display': T(33108, ' Critic Rating'), 'defSortDesc': True}, + 'audienceRating': {'title': T(33101, 'By Audience Rating'), 'display': T(33102, 'Audience Rating'), + 'defSortDesc': True}, + # called "Rating" in PlexWeb, using more obvious "This is this user's rating" here + 'userRating': {'title': T(33103, 'By my Rating'), 'display': T(33104, 'My Rating'), 'defSortDesc': True}, + 'contentRating': {'title': T(33105, 'By Content Rating'), 'display': T(33106, 'Content Rating'), + 'defSortDesc': True}, + 'resolution': {'title': T(32361, 'By Resolution'), 'display': T(32362, 'Resolution'), 'defSortDesc': True}, + 'duration': {'title': T(32363, 'By Duration'), 'display': T(32364, 'Duration'), 'defSortDesc': True}, + 'unwatched': {'title': T(32367, 'By Unplayed'), 'display': T(32368, 'Unplayed'), 'defSortDesc': False}, + 'viewCount': {'title': T(32371, 'By Play Count'), 'display': T(32372, 'Play Count'), 'defSortDesc': True} + }, + 'show': { + 'titleSort': {'title': T(32357, 'By Name'), 'display': T(32358, 'Name'), 'defSortDesc': False}, + 'show.titleSort': {'title': T(32457, 'By Show'), 'display': T(32456, 'Show'), 'defSortDesc': False}, + 'originallyAvailableAt': {'title': T(32365, 'By First Aired'), 'display': T(32366, 'First Aired'), + 'defSortDesc': False}, + 'unviewedLeafCount': {'title': T(32367, 'By Unplayed'), 'display': T(32368, 'Unplayed'), 'defSortDesc': True}, + 'rating': {'title': T(33107, 'By Critic Rating'), 'display': T(33108, ' Critic Rating'), 'defSortDesc': True}, + 'audienceRating': {'title': T(33101, 'By Audience Rating'), 'display': T(33102, 'Audience Rating'), + 'defSortDesc': True}, + # called "Rating" in PlexWeb, using more obvious "This is this user's rating" here + 'userRating': {'title': T(33103, 'By my Rating'), 'display': T(33104, 'My Rating'), 'defSortDesc': True}, + 'contentRating': {'title': T(33105, 'By Content Rating'), 'display': T(33106, 'Content Rating'), + 'defSortDesc': True}, + }, + 'artist': { + 'titleSort': {'title': T(32357, 'By Name'), 'display': T(32358, 'Name'), 'defSortDesc': False}, + 'artist.titleSort': {'title': T(32463, 'By Artist'), 'display': T(32462, 'Artist'), 'defSortDesc': False}, + 'lastViewedAt': {'title': T(32369, 'By Date Played'), 'display': T(32370, 'Date Played'), 'defSortDesc': False}, + }, + 'photo': { + 'titleSort': {'title': T(32357, 'By Name'), 'display': T(32358, 'Name'), 'defSortDesc': False}, + 'originallyAvailableAt': {'title': T(32373, 'By Date Taken'), 'display': T(32374, 'Date Taken'), + 'defSortDesc': True} + }, + 'photodirectory': {}, + 'collection': {} +} + +ITEM_TYPE = None + + +def setItemType(type_=None): + assert type_ is not None, "Invalid type: None" + global ITEM_TYPE + ITEM_TYPE = type_ + util.setGlobalProperty('item.type', str(ITEM_TYPE)) + + +class ChunkRequestTask(backgroundthread.Task): + def setup(self, section, start, size, callback, filter_=None, sort=None, unwatched=False, subDir=False): + self.section = section + self.start = start + self.size = size + self.callback = callback + self.filter = filter_ + self.sort = sort + self.unwatched = unwatched + self.subDir = subDir + return self + + def contains(self, pos): + return self.start <= pos <= (self.start + self.size) + + def run(self): + if self.isCanceled(): + return + + try: + type_ = None + if ITEM_TYPE == 'episode': + type_ = 4 + elif ITEM_TYPE == 'album': + type_ = 9 + elif ITEM_TYPE == 'collection': + type_ = 18 + + if ITEM_TYPE == 'folder': + items = self.section.folder(self.start, self.size, self.subDir) + else: + items = self.section.all(self.start, self.size, self.filter, self.sort, self.unwatched, type_=type_) + + if self.isCanceled(): + return + self.callback(items, self.start) + except plexnet.exceptions.BadRequest: + util.DEBUG_LOG('404 on section: {0}'.format(repr(self.section.title))) + + +class PhotoPropertiesTask(backgroundthread.Task): + def setup(self, photo, callback): + self.photo = photo + self.callback = callback + return self + + def run(self): + if self.isCanceled(): + return + + try: + self.photo.reload() + self.callback(self.photo) + except plexnet.exceptions.BadRequest: + util.DEBUG_LOG('404 on photo reload: {0}'.format(self.photo)) + + +class LibrarySettings(object): + def __init__(self, section_or_server_id, ignoreLibrarySettings=False): + self.ignoreLibrarySettings = ignoreLibrarySettings + if isinstance(section_or_server_id, six.string_types): + self.serverID = section_or_server_id + self.sectionID = None + else: + self.serverID = section_or_server_id.getServer().uuid + self.sectionID = section_or_server_id.key + + self._loadSettings() + + def _loadSettings(self): + if self.ignoreLibrarySettings: + self._settings = {} + return + + if not self.sectionID: + return + + jsonString = util.getSetting('library.settings.{0}'.format(self.serverID), '') + self._settings = {} + try: + self._settings = json.loads(jsonString) + except ValueError: + pass + except: + util.ERROR() + + setItemType(self.getItemType() or ITEM_TYPE) + + def getItemType(self): + if not self._settings or self.sectionID not in self._settings: + return None + + return self._settings[self.sectionID].get('ITEM_TYPE') + + def setItemType(self, item_type): + setItemType(item_type) + + if self.sectionID not in self._settings: + self._settings[self.sectionID] = {} + + self._settings[self.sectionID]['ITEM_TYPE'] = item_type + + self._saveSettings() + + def _saveSettings(self): + jsonString = json.dumps(self._settings) + util.setSetting('library.settings.{0}'.format(self.serverID), jsonString) + + def setSection(self, section_id): + self.sectionID = section_id + + def getSetting(self, setting, default=None): + if not self._settings or self.sectionID not in self._settings: + return default + + if ITEM_TYPE not in self._settings[self.sectionID]: + return default + + return self._settings[self.sectionID][ITEM_TYPE].get(setting, default) + + def setSetting(self, setting, value): + if self.sectionID not in self._settings: + self._settings[self.sectionID] = {} + + if ITEM_TYPE not in self._settings[self.sectionID]: + self._settings[self.sectionID][ITEM_TYPE] = {} + + self._settings[self.sectionID][ITEM_TYPE][setting] = value + + self._saveSettings() + + +class ChunkedWrapList(kodigui.ManagedControlList): + LIST_MAX = CHUNK_SIZE * 3 + + def __getitem__(self, idx): + # if isinstance(idx, slice): + # return self.items[idx] + # else: + idx = idx % self.LIST_MAX + return self.items[idx] + # return self.getListItem(idx) + + +class ChunkModeWrapped(object): + ALL_MAX = CHUNK_SIZE * 2 + + def __init__(self): + self.reset() + + def reset(self): + self.midStart = 0 + self.itemCount = 0 + self.keys = {} + + def addKeyRange(self, key, krange): + self.keys[key] = krange + + def getKey(self, pos): + for k, krange in self.keys.items(): + if krange[0] <= pos <= krange[1]: + return k + + def isAtBeginning(self): + return self.midStart == 0 + + def posIsForward(self, pos): + if self.itemCount <= self.ALL_MAX: + return False + return pos >= self.midStart + CHUNK_SIZE + + def posIsBackward(self, pos): + if self.itemCount <= self.ALL_MAX: + return False + return pos < self.midStart + + def posIsValid(self, pos): + return self.midStart - CHUNK_SIZE <= pos < self.midStart + (CHUNK_SIZE * 2) + + def shift(self, mod): + if mod < 0 and self.midStart == 0: + return None + elif mod > 0 and self.midStart + CHUNK_SIZE >= self.itemCount: + return None + + offset = CHUNK_SIZE * mod + self.midStart += offset + start = self.midStart + offset + + return start + + def shiftToKey(self, key, keyStart=None): + if keyStart is None: + if key not in self.keys: + util.DEBUG_LOG('CHUNK MODE: NO ITEMS FOR KEY') + return + + keyStart = self.keys[key][0] + self.midStart = keyStart - keyStart % CHUNK_SIZE + return keyStart, max(self.midStart - CHUNK_SIZE, 0) + + def addObjects(self, pos, objects): + if not self.posIsValid(pos): + return + + if pos == self.midStart - CHUNK_SIZE: + self.objects = objects + self.objects[CHUNK_SIZE:] + elif pos == self.midStart: + self.objects = self.objects[:CHUNK_SIZE] + objects + self.objects[CHUNK_SIZE * 2:] + elif pos == self.midStart + CHUNK_SIZE: + self.objects = self.objects[:CHUNK_SIZE * 2] + objects + + +class CustomScrollBar(object): + def __init__(self, window, bar_group_id, bar_image_id, bar_image_focus_id, button_id, min_bar_height=20): + self._barGroup = window.getControl(bar_group_id) + self._barImage = window.getControl(bar_image_id) + self._barImageFocus = window.getControl(bar_image_focus_id) + self._button = window.getControl(button_id) + self.height = self._button.getHeight() + self.x, self.y = self._barGroup.getPosition() + self._minBarHeight = min_bar_height + self._barHeight = min_bar_height + self.reset() + + def reset(self): + self.size = 0 + self.count = 0 + self.pos = 0 + + def setSizeAndCount(self, size, count): + self.size = size + self.count = count + self._barHeight = min(self.height, max(self._minBarHeight, int(self.height * (count / float(size))))) + self._moveHeight = self.height - self._barHeight + self._barImage.setHeight(self._barHeight) + self._barImageFocus.setHeight(self._barHeight) + self.setPosition(0) + + def setPosition(self, pos): + self.pos = pos + offset = int((pos / float(max(self.size, 2) - 1)) * self._moveHeight) + self._barGroup.setPosition(self.x, self.y + offset) + + def getPosFromY(self, y): + y -= int(self._barHeight / 2) + 150 + y = min(max(y, 0), self._moveHeight) + return int((self.size - 1) * (y / float(self._moveHeight))) + + def onMouseDrag(self, window, action): + y = window.mouseYTrans(action.getAmount2()) + y -= int(self._barHeight / 2) + 150 + y = min(max(y, 0), self._moveHeight) + self._barGroup.setPosition(self.x, self.y) + + +class LibraryWindow(kodigui.MultiWindow, windowutils.UtilMixin): + bgXML = 'script-plex-blank.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + + def __init__(self, *args, **kwargs): + kodigui.MultiWindow.__init__(self, *args, **kwargs) + windowutils.UtilMixin.__init__(self) + self.section = kwargs.get('section') + self.filter = kwargs.get('filter_') + self.subDir = kwargs.get('subDir') + self.keyItems = {} + self.firstOfKeyItems = {} + self.tasks = backgroundthread.Tasks() + self.backgroundSet = False + self.showPanelControl = None + self.keyListControl = None + self.lastItem = None + self.lastFocusID = None + self.lastNonOptionsFocusID = None + self.refill = False + + self.dcpjPos = 0 + self.dcpjThread = None + self.dcpjTimeout = 0 + + self.dragging = False + + self.cleared = True + self.librarySettings = LibrarySettings(self.section, + ignoreLibrarySettings=kwargs.get("ignoreLibrarySettings", False)) + self.reset() + + self.lock = threading.Lock() + + def reset(self): + util.setGlobalProperty('sort', '') + self.filterUnwatched = self.librarySettings.getSetting('filter.unwatched', False) + self.sort = self.librarySettings.getSetting('sort', 'titleSort') + self.sortDesc = self.librarySettings.getSetting('sort.desc', False) + + self.chunkMode = None + #if ITEM_TYPE in ('episode', 'album'): + # self.chunkMode = ChunkModeWrapped() + + key = self.section.key + if not key.isdigit(): + key = self.section.getLibrarySectionId() + viewtype = util.getSetting('viewtype.{0}.{1}'.format(self.section.server.uuid, key)) + + if self.chunkMode: + if self.section.TYPE in ('artist', 'photo', 'photodirectory'): + self.setWindows(VIEWS_SQUARE_CHUNKED.get('all')) + self.setDefault(VIEWS_SQUARE_CHUNKED.get(viewtype)) + else: + self.setWindows(VIEWS_POSTER_CHUNKED.get('all')) + self.setDefault(VIEWS_POSTER_CHUNKED.get(viewtype)) + else: + if self.section.TYPE in ('artist', 'photo', 'photodirectory'): + self.setWindows(VIEWS_SQUARE.get('all')) + self.setDefault(VIEWS_SQUARE.get(viewtype)) + else: + self.setWindows(VIEWS_POSTER.get('all')) + self.setDefault(VIEWS_POSTER.get(viewtype)) + + @busy.dialog() + def doClose(self): + self.tasks.kill() + kodigui.MultiWindow.doClose(self) + + def onFirstInit(self): + self.scrollBar = None + #if ITEM_TYPE in ('episode', 'album'): + # self.scrollBar = CustomScrollBar(self, 950, 952, 953, 951) + + if self.showPanelControl and not self.refill: + self.showPanelControl.newControl(self) + self.keyListControl.newControl(self) + self.showPanelControl.selectItem(0) + self.setFocusId(self.VIEWTYPE_BUTTON_ID) + self.setBoolProperty("initialized", True) + else: + if self.chunkMode: + self.showPanelControl = ChunkedWrapList(self, self.POSTERS_PANEL_ID, 5) + else: + self.showPanelControl = kodigui.ManagedControlList(self, self.POSTERS_PANEL_ID, 5) + + hideFilterOptions = self.section.TYPE == 'photodirectory' or self.section.TYPE == 'collection' + + self.keyListControl = kodigui.ManagedControlList(self, self.KEY_LIST_ID, 27) + self.setProperty('subDir', self.subDir and '1' or '') + self.setProperty('no.options', self.section.TYPE != 'photodirectory' and '1' or '') + self.setProperty('unwatched.hascount', self.section.TYPE == 'show' and '1' or '') + util.setGlobalProperty('sort', self.sort) + self.setProperty('filter1.display', self.filterUnwatched and T(32368, 'UNPLAYED') or T(32345, 'All')) + self.setProperty('sort.display', SORT_KEYS[self.section.TYPE].get(self.sort, SORT_KEYS['movie'].get(self.sort))['title']) + self.setProperty('media.itemType', ITEM_TYPE or self.section.TYPE) + self.setProperty('media.type', TYPE_PLURAL.get(ITEM_TYPE or self.section.TYPE, self.section.TYPE)) + self.setProperty('media', self.section.TYPE) + self.setProperty('hide.filteroptions', hideFilterOptions and '1' or '') + + self.setTitle() + self.setBoolProperty("initialized", True) + self.fill() + self.refill = False + if self.getProperty('no.content') or self.getProperty('no.content.filtered'): + self.setFocusId(self.HOME_BUTTON_ID) + else: + self.setFocusId(self.POSTERS_PANEL_ID) + + def onAction(self, action): + try: + if self.dragging: + if not action == xbmcgui.ACTION_MOUSE_DRAG: + self.dragging = False + self.setBoolProperty('dragging', self.dragging) + + if action.getId() in MOVE_SET: + if util.advancedSettings.dynamicBackgrounds: + mli = self.showPanelControl.getSelectedItem() + if mli and mli.dataSource: + self.updateBackgroundFrom(mli.dataSource) + + controlID = self.getFocusId() + if controlID == self.POSTERS_PANEL_ID or controlID == self.SCROLLBAR_ID: + self.updateKey() + self.checkChunkedNav(action) + elif controlID == self.CUSTOM_SCOLLBAR_BUTTON_ID: + if action == xbmcgui.ACTION_MOVE_UP: + self.shiftSelection(-12) + elif action == xbmcgui.ACTION_MOVE_DOWN: + self.shiftSelection(12) + # elif action == xbmcgui.KEY_MOUSE_DRAG_START: + # self.onMouseDragStart(action) + # elif action == xbmcgui.KEY_MOUSE_DRAG_END: + # self.onMouseDragEnd(action) + elif action == xbmcgui.ACTION_MOUSE_DRAG: + self.onMouseDrag(action) + elif action == xbmcgui.ACTION_CONTEXT_MENU: + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + self.lastNonOptionsFocusID = self.lastFocusID + self.setFocusId(self.OPTIONS_GROUP_ID) + return + else: + if self.lastNonOptionsFocusID: + self.setFocusId(self.lastNonOptionsFocusID) + self.lastNonOptionsFocusID = None + return + + elif action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)) and \ + (not util.advancedSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU): + if xbmc.getCondVisibility('Integer.IsGreater(Container(101).ListItem.Property(index),5)'): + self.showPanelControl.selectItem(0) + return + + self.updateItem() + + except: + util.ERROR() + + kodigui.MultiWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.HOME_BUTTON_ID: + self.goHome() + elif controlID == self.POSTERS_PANEL_ID: + self.showPanelClicked() + elif controlID == self.KEY_LIST_ID: + self.keyClicked() + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + elif controlID == self.PLAY_BUTTON_ID: + self.playButtonClicked() + elif controlID == self.SHUFFLE_BUTTON_ID: + self.shuffleButtonClicked() + elif controlID == self.OPTIONS_BUTTON_ID: + self.optionsButtonClicked() + elif controlID == self.VIEWTYPE_BUTTON_ID: + self.viewTypeButtonClicked() + elif controlID == self.SORT_BUTTON_ID: + self.sortButtonClicked() + elif controlID == self.FILTER1_BUTTON_ID: + self.filter1ButtonClicked() + elif controlID == self.ITEM_TYPE_BUTTON_ID: + self.itemTypeButtonClicked() + elif controlID == self.SEARCH_BUTTON_ID: + self.searchButtonClicked() + + def onFocus(self, controlID): + self.lastFocusID = controlID + + if controlID == self.KEY_LIST_ID: + self.selectKey() + + if player.PLAYER.bgmPlaying: + player.PLAYER.stopAndWait() + + def onItemChanged(self, mli): + if not mli: + return + + if not mli.dataSource or not mli.dataSource.TYPE == 'photo': + return + + self.showPhotoItemProperties(mli.dataSource) + + def onMouseDragStart(self, action): + if not self.scrollBar: + return + + controlID = self.getFocusId() + if controlID != self.CUSTOM_SCOLLBAR_BUTTON_ID: + return + + self.dragging = True + self.setBoolProperty('dragging', self.dragging) + + def onMouseDragEnd(self, action): + if not self.scrollBar: + return + + if not self.dragging: + return + + self.dragging = False + self.setBoolProperty('dragging', self.dragging) + + y = self.mouseYTrans(action.getAmount2()) + + pos = self.scrollBar.getPosFromY(y) + self.shiftSelection(pos=pos) + + def onMouseDrag(self, action): + if not self.scrollBar: + return + + if not self.dragging: + controlID = self.getFocusId() + if controlID != self.CUSTOM_SCOLLBAR_BUTTON_ID: + return + + self.onMouseDragStart(action) + if not self.dragging: + return + + # self.scrollBar.onMouseDrag(self, action) + + y = self.mouseYTrans(action.getAmount2()) + + pos = self.scrollBar.getPosFromY(y) + if self.chunkMode.posIsForward(pos) or self.chunkMode.posIsBackward(pos): + self.shiftSelection(pos=pos) + else: + self.showPanelControl.selectItem(pos) + self.checkChunkedNav() + + def shiftSelection(self, offset=0, pos=None): + if pos is not None: + self.scrollBar.setPosition(pos) + return self.delayedChunkedPosJump(pos) + else: + mli = self.showPanelControl.getSelectedItem() + + try: + idx = int(mli.getProperty('index')) + except ValueError: + return + + target = idx + offset + if target >= self.chunkMode.itemCount: + pos = self.chunkMode.itemCount - 1 + elif target < 0: + pos = 0 + else: + pos = self.showPanelControl.getSelectedPosition() + pos += offset + + if pos < 0 or pos >= self.showPanelControl.size(): + pos = pos % self.showPanelControl.size() + + self.showPanelControl.selectItem(pos) + self.checkChunkedNav(idx=pos) + + def updateKey(self, mli=None): + mli = mli or self.showPanelControl.getSelectedItem() + if not mli: + return + + if self.lastItem != mli: + self.lastItem = mli + self.onItemChanged(mli) + + util.setGlobalProperty('key', mli.getProperty('key')) + + self.selectKey(mli) + + def checkChunkedNav(self, action=None, idx=None): + if not self.chunkMode: + return + + # if action == xbmcgui.ACTION_PAGE_DOWN: + # idx = self.showPanelControl.getSelectedPosition() - 5 + # if idx < 0: + # idx += self.showPanelControl.size() + # mli = self.showPanelControl.getListItem(idx) + # self.showPanelControl.selectItem(idx) + # elif action == xbmcgui.ACTION_PAGE_UP: + # idx = self.showPanelControl.getSelectedPosition() + 5 + # if idx >= self.showPanelControl.size(): + # idx %= self.showPanelControl.size() + # mli = self.showPanelControl.getListItem(idx) + # self.showPanelControl.selectItem(idx) + # else: + mli = self.showPanelControl.getSelectedItem() + + try: + if idx is not None: + pos = int(self.showPanelControl[idx].getProperty('index')) + else: + pos = int(mli.getProperty('index')) + + if pos >= self.chunkMode.itemCount: + raise ValueError + except ValueError: + if self.chunkMode.isAtBeginning() and action not in (xbmcgui.ACTION_MOVE_DOWN, xbmcgui.ACTION_PAGE_DOWN): + idx = 0 + else: + idx = ((self.chunkMode.itemCount - 1) % self.showPanelControl.LIST_MAX) + + self.showPanelControl.selectItem(idx) + mli = self.showPanelControl[idx] + self.updateKey(mli) + if self.scrollBar: + try: + pos = int(mli.getProperty('index')) + self.scrollBar.setPosition(pos) + except ValueError: + pass + + if idx == 0 and action == xbmcgui.ACTION_MOVE_UP: + self.setFocusId(600) + + return + + if self.scrollBar: + self.scrollBar.setPosition(pos) + + if self.chunkMode.posIsForward(pos): + self.shiftChunks() + elif self.chunkMode.posIsBackward(pos): + self.shiftChunks(-1) + + def shiftChunks(self, mod=1): + start = self.chunkMode.shift(mod) + if start is None: + return + + if start < 0: + self.chunkCallback([None] * CHUNK_SIZE, -CHUNK_SIZE) + else: + self.chunkCallback([False] * CHUNK_SIZE, start) + task = ChunkRequestTask().setup( + self.section, start, CHUNK_SIZE, self.chunkCallback, filter_=self.getFilterOpts(), sort=self.getSortOpts(), unwatched=self.filterUnwatched, subDir=self.subDir + ) + + self.tasks.add(task) + backgroundthread.BGThreader.addTasksToFront([task]) + + def selectKey(self, mli=None): + if not mli: + mli = self.showPanelControl.getSelectedItem() + if not mli: + return + + li = self.keyItems.get(mli.getProperty('key')) + if not li: + return + self.keyListControl.selectItem(li.pos()) + + def searchButtonClicked(self): + self.processCommand(search.dialog(self, section_id=self.section.key)) + + def delayedChunkedPosJump(self, pos): + if not self.cleared: + self.chunkCallback(None, None, clear=True) + self.dcpjTimeout = time.time() + 0.5 + self.dcpjPos = pos + if not self.dcpjThread or not self.dcpjThread.is_alive(): + self.dcpjThread = threading.Thread(target=self._chunkedPosJump) + self.dcpjThread.start() + + def _chunkedPosJump(self): + while not util.MONITOR.waitForAbort(0.1): + if time.time() >= self.dcpjTimeout: + break + else: + return + + keyStart_start = self.chunkMode.shiftToKey(None, keyStart=self.dcpjPos) + if not keyStart_start: + return + + keyStart, start = keyStart_start + pos = keyStart % self.showPanelControl.LIST_MAX + self.chunkedPosJump(pos, start) + self.showPanelControl.selectItem(pos) + + def chunkedPosJump(self, pos, start=None): + if start is None: + start = max(pos - CHUNK_SIZE, 0) + + mul = 3 + if not start: + mul = 2 + + tasks = [] + for x in range(mul): + task = ChunkRequestTask().setup( + self.section, + start + (CHUNK_SIZE * x), + CHUNK_SIZE, + self.chunkCallback, + filter_=self.getFilterOpts(), + sort=self.getSortOpts(), + unwatched=self.filterUnwatched, + subDir=self.subDir + ) + + self.tasks.add(task) + tasks.append(task) + + mid = tasks.pop(1) + backgroundthread.BGThreader.addTasksToFront([mid] + tasks) + + def keyClicked(self): + li = self.keyListControl.getSelectedItem() + if not li: + return + + if self.chunkMode: + keyStart_start = self.chunkMode.shiftToKey(li.dataSource) + if not keyStart_start: + return + keyStart, start = keyStart_start + + pos = keyStart % self.showPanelControl.LIST_MAX + self.chunkedPosJump(pos, start) + else: + mli = self.firstOfKeyItems.get(li.dataSource) + if not mli: + return + pos = mli.pos() + + self.showPanelControl.selectItem(pos) + self.setFocusId(self.POSTERS_PANEL_ID) + util.setGlobalProperty('key', li.dataSource) + + def playButtonClicked(self, shuffle=False): + filter_ = self.getFilterOpts() + sort = self.getSortOpts() + args = {} + if filter_: + args[filter_[0]] = filter_[1] + + if sort: + args['sort'] = '{0}:{1}'.format(*sort) + + if self.section.TYPE == 'movie': + args['sourceType'] = '1' + elif self.section.TYPE == 'show': + args['sourceType'] = '2' + elif self.section.TYPE != 'collection': + args['sourceType'] = '8' + + # When the list is filtered by unwatched, play and shuffle button should only play unwatched videos + if self.filterUnwatched: + args['unwatched'] = '1' + + pq = playqueue.createPlayQueueForItem(self.section, options={'shuffle': shuffle}, args=args) + opener.open(pq) + + def shuffleButtonClicked(self): + self.playButtonClicked(shuffle=True) + + def optionsButtonClicked(self): + options = [] + if xbmc.getCondVisibility('Player.HasAudio + MusicPlayer.HasNext'): + options.append({'key': 'play_next', 'display': T(32325, 'Play Next')}) + + # if self.section.TYPE not in ('artist', 'photo', 'photodirectory'): + # options.append({'key': 'mark_watched', 'display': 'Mark All Watched'}) + # options.append({'key': 'mark_unwatched', 'display': 'Mark All Unwatched'}) + + # if xbmc.getCondVisibility('Player.HasAudio') and self.section.TYPE == 'artist': + # options.append({'key': 'add_to_queue', 'display': 'Add To Queue'}) + + # if False: + # options.append({'key': 'add_to_playlist', 'display': 'Add To Playlist'}) + + if self.section.TYPE == 'photodirectory': + if options: + options.append(dropdown.SEPARATOR) + options.append({'key': 'to_section', 'display': T(32324, u'Go to {0}').format(self.section.getLibrarySectionTitle())}) + + choice = dropdown.showDropdown(options, (255, 205)) + if not choice: + return + + if choice['key'] == 'play_next': + xbmc.executebuiltin('PlayerControl(Next)') + elif choice['key'] == 'to_section': + self.goHome(self.section.getLibrarySectionId()) + + def itemTypeButtonClicked(self): + options = [] + + if self.section.TYPE == 'show': + for t in ('show', 'episode', 'collection'): + options.append({'type': t, 'display': TYPE_PLURAL.get(t, t)}) + elif self.section.TYPE == 'movie': + for t in ('movie', 'collection', 'folder'): + options.append({'type': t, 'display': TYPE_PLURAL.get(t, t)}) + elif self.section.TYPE == 'artist': + for t in ('artist', 'album', 'collection'): + options.append({'type': t, 'display': TYPE_PLURAL.get(t, t)}) + else: + return + + result = dropdown.showDropdown(options, (1280, 106), with_indicator=True) + if not result: + return + + choice = result['type'] + + if choice == ITEM_TYPE: + return + + self.tasks.cancel() + + self.showPanelControl = None # TODO: Need to do some check here I think + + self.librarySettings.setItemType(choice) + + self.reset() + + self.clearFilters() + self.resetSort() + + if not self.nextWindow(False): + self.setProperty('media.type', TYPE_PLURAL.get(ITEM_TYPE or self.section.TYPE, self.section.TYPE)) + self.setProperty('sort.display', SORT_KEYS[self.section.TYPE].get(self.sort, SORT_KEYS['movie'].get(self.sort))['title']) + self.fill() + + def sortButtonClicked(self): + desc = 'script.plex/indicators/arrow-down.png' + asc = 'script.plex/indicators/arrow-up.png' + ind = self.sortDesc and desc or asc + + options = [] + defSortByOption = {} + + if self.section.TYPE == 'movie': + searchTypes = ['titleSort', 'addedAt', 'originallyAvailableAt', 'lastViewedAt', 'rating', 'audienceRating', + 'userRating', 'contentRating', 'resolution', 'duration'] + if ITEM_TYPE == 'collection': + searchTypes = ['titleSort', 'addedAt', 'contentRating'] + + for stype in searchTypes: + option = SORT_KEYS['movie'].get(stype).copy() + option['type'] = stype + option['indicator'] = self.sort == stype and ind or '' + defSortByOption[stype] = option.get('defSortDesc') + options.append(option) + elif self.section.TYPE == 'show': + searchTypes = ['titleSort', 'addedAt', 'lastViewedAt', 'originallyAvailableAt', 'rating', + 'audienceRating', 'userRating', 'contentRating', 'unviewedLeafCount'] + if ITEM_TYPE == 'episode': + searchTypes = ['titleSort', 'show.titleSort', 'addedAt', 'originallyAvailableAt', 'lastViewedAt', 'rating', + 'audienceRating', 'userRating'] + elif ITEM_TYPE == 'collection': + searchTypes = ['titleSort', 'addedAt'] + + for stype in searchTypes: + option = SORT_KEYS['show'].get(stype, SORT_KEYS['movie'].get(stype)).copy() + option['type'] = stype + option['indicator'] = self.sort == stype and ind or '' + defSortByOption[stype] = option.get('defSortDesc') + options.append(option) + elif self.section.TYPE == 'artist': + searchTypes = ['titleSort', 'addedAt', 'lastViewedAt', 'viewCount'] + if ITEM_TYPE == 'album': + searchTypes = ['titleSort', 'artist.titleSort', 'addedAt', 'lastViewedAt', 'viewCount', 'originallyAvailableAt', 'rating'] + elif ITEM_TYPE == 'collection': + searchTypes = ['titleSort', 'addedAt'] + + for stype in searchTypes: + option = SORT_KEYS['artist'].get(stype, SORT_KEYS['movie'].get(stype)).copy() + option['type'] = stype + option['indicator'] = self.sort == stype and ind or '' + defSortByOption[stype] = option.get('defSortDesc') + options.append(option) + elif self.section.TYPE == 'photo': + searchTypes = ['titleSort', 'addedAt', 'originallyAvailableAt', 'rating'] + for stype in searchTypes: + option = SORT_KEYS['photo'].get(stype, SORT_KEYS['movie'].get(stype)).copy() + option['type'] = stype + option['indicator'] = self.sort == stype and ind or '' + defSortByOption[stype] = option.get('defSortDesc') + options.append(option) + else: + return + + result = dropdown.showDropdown(options, (1280, 106), with_indicator=True) + if not result: + return + + choice = result['type'] + + forceRefresh = False + if choice == self.sort: + self.sortDesc = not self.sortDesc + else: + self.sortDesc = defSortByOption.get(choice, False) + if choice == 'titleSort': + forceRefresh = True + + self.sort = choice + + self.librarySettings.setSetting('sort', self.sort) + self.librarySettings.setSetting('sort.desc', self.sortDesc) + + util.setGlobalProperty('sort', choice) + self.setProperty('sort.display', result['title']) + + self.sortShowPanel(choice, forceRefresh) + + def viewTypeButtonClicked(self): + for task in self.tasks: + if task.isValid(): + task.cancel() + self.refill = True + + with self.lock: + self.showPanelControl.invalidate() + win = self.nextWindow() + + key = self.section.key + if not key.isdigit(): + key = self.section.getLibrarySectionId() + util.setSetting('viewtype.{0}.{1}'.format(self.section.server.uuid, key), win.VIEWTYPE) + + def sortShowPanel(self, choice, force_refresh=False): + if force_refresh or self.chunkMode or self.showPanelControl.size() == 0: + self.fillShows() + return + + if choice == 'addedAt': + self.showPanelControl.sort(lambda i: i.dataSource.addedAt, reverse=self.sortDesc) + elif choice == 'originallyAvailableAt': + self.showPanelControl.sort(lambda i: i.dataSource.get('originallyAvailableAt'), reverse=self.sortDesc) + elif choice == 'lastViewedAt': + self.showPanelControl.sort(lambda i: i.dataSource.get('lastViewedAt'), reverse=self.sortDesc) + elif choice == 'viewCount': + self.showPanelControl.sort(lambda i: i.dataSource.get('titleSort') or i.dataSource.title) + self.showPanelControl.sort(lambda i: i.dataSource.get('viewCount').asInt(), reverse=self.sortDesc) + elif choice == 'titleSort': + self.showPanelControl.sort(lambda i: i.dataSource.get('titleSort') or i.dataSource.title, reverse=self.sortDesc) + self.keyListControl.sort(lambda i: i.getProperty('original'), reverse=self.sortDesc) + elif choice == 'show.titleSort': + self.showPanelControl.sort(lambda i: i.label, reverse=self.sortDesc) + self.keyListControl.sort(lambda i: i.getProperty('original'), reverse=self.sortDesc) + elif choice == 'artist.titleSort': + self.showPanelControl.sort(lambda i: i.label, reverse=self.sortDesc) + self.keyListControl.sort(lambda i: i.getProperty('original'), reverse=self.sortDesc) + elif choice == 'rating': + self.showPanelControl.sort(lambda i: i.dataSource.get('titleSort') or i.dataSource.title) + self.showPanelControl.sort(lambda i: i.dataSource.get('rating').asFloat(), reverse=self.sortDesc) + elif choice == 'audienceRating': + self.showPanelControl.sort(lambda i: i.dataSource.get('titleSort') or i.dataSource.title) + self.showPanelControl.sort(lambda i: i.dataSource.get('audienceRating').asFloat(), reverse=self.sortDesc) + elif choice == 'userRating': + self.showPanelControl.sort(lambda i: i.dataSource.get('titleSort') or i.dataSource.title) + self.showPanelControl.sort(lambda i: i.dataSource.get('userRating').asFloat(), reverse=self.sortDesc) + elif choice == 'contentRating': + self.showPanelControl.sort(lambda i: i.dataSource.get('titleSort') or i.dataSource.title) + self.showPanelControl.sort(lambda i: i.dataSource.get('contentRating'), reverse=self.sortDesc) + elif choice == 'resolution': + self.showPanelControl.sort(lambda i: i.dataSource.maxHeight, reverse=self.sortDesc) + elif choice == 'duration': + self.showPanelControl.sort(lambda i: i.dataSource.duration.asInt(), reverse=self.sortDesc) + elif choice == 'unviewedLeafCount': + self.showPanelControl.sort(lambda i: i.dataSource.unViewedLeafCount, reverse=self.sortDesc) + + self.showPanelControl.selectItem(0) + self.setFocusId(self.POSTERS_PANEL_ID) + self.backgroundSet = False + self.setBackground([item.dataSource for item in self.showPanelControl], 0, randomize=not util.advancedSettings.dynamicBackgrounds) + + def subOptionCallback(self, option): + check = 'script.plex/home/device/check.png' + options = None + subKey = None + if self.filter: + if self.filter.get('sub'): + subKey = self.filter['sub']['val'] + + if option['type'] in ( + 'year', 'decade', 'genre', 'contentRating', 'collection', 'director', 'actor', 'country', 'studio', 'resolution', 'labels', + 'make', 'model', 'aperture', 'exposure', 'iso', 'lens' + ): + options = [{'val': o.key, 'display': o.title, 'indicator': o.key == subKey and check or ''} for o in self.section.listChoices(option['type'])] + if not options: + options = [{'val': None, 'display': T(32375, 'No filters available'), 'ignore': True}] + + return options + + def hasFilter(self, ftype): + if not self.filter: + return False + + return self.filter['type'] == ftype + + def filter1ButtonClicked(self): + check = 'script.plex/home/device/check.png' + + options = [] + + if self.section.TYPE in ('movie', 'show'): + options.append({'type': 'unwatched', 'display': T(32368, 'UNPLAYED').upper(), 'indicator': self.filterUnwatched and check or ''}) + + if self.filter: + options.append({'type': 'clear_filter', 'display': T(32376, 'CLEAR FILTER').upper(), 'indicator': 'script.plex/indicators/remove.png'}) + + if options: + options.append(None) # Separator + + optionsMap = { + 'year': {'type': 'year', 'display': T(32377, 'Year'), 'indicator': self.hasFilter('year') and check or ''}, + 'decade': {'type': 'decade', 'display': T(32378, 'Decade'), 'indicator': self.hasFilter('decade') and check or ''}, + 'genre': {'type': 'genre', 'display': T(32379, 'Genre'), 'indicator': self.hasFilter('genre') and check or ''}, + 'contentRating': {'type': 'contentRating', 'display': T(32380, 'Content Rating'), 'indicator': self.hasFilter('contentRating') and check or ''}, + 'network': {'type': 'studio', 'display': T(32381, 'Network'), 'indicator': self.hasFilter('studio') and check or ''}, + 'collection': {'type': 'collection', 'display': T(32382, 'Collection'), 'indicator': self.hasFilter('collection') and check or ''}, + 'director': {'type': 'director', 'display': T(32383, 'Director'), 'indicator': self.hasFilter('director') and check or ''}, + 'actor': {'type': 'actor', 'display': T(32384, 'Actor'), 'indicator': self.hasFilter('actor') and check or ''}, + 'country': {'type': 'country', 'display': T(32385, 'Country'), 'indicator': self.hasFilter('country') and check or ''}, + 'studio': {'type': 'studio', 'display': T(32386, 'Studio'), 'indicator': self.hasFilter('studio') and check or ''}, + 'resolution': {'type': 'resolution', 'display': T(32362, 'Resolution'), 'indicator': self.hasFilter('resolution') and check or ''}, + 'labels': {'type': 'labels', 'display': T(32387, 'Labels'), 'indicator': self.hasFilter('labels') and check or ''}, + + 'make': {'type': 'make', 'display': T(32388, 'Camera Make'), 'indicator': self.hasFilter('make') and check or ''}, + 'model': {'type': 'model', 'display': T(32389, 'Camera Model'), 'indicator': self.hasFilter('model') and check or ''}, + 'aperture': {'type': 'aperture', 'display': T(32390, 'Aperture'), 'indicator': self.hasFilter('aperture') and check or ''}, + 'exposure': {'type': 'exposure', 'display': T(32391, 'Shutter Speed'), 'indicator': self.hasFilter('exposure') and check or ''}, + 'iso': {'type': 'iso', 'display': 'ISO', 'indicator': self.hasFilter('iso') and check or ''}, + 'lens': {'type': 'lens', 'display': T(32392, 'Lens'), 'indicator': self.hasFilter('lens') and check or ''} + } + + if self.section.TYPE == 'movie': + for k in ('year', 'decade', 'genre', 'contentRating', 'collection', 'director', 'actor', 'country', 'studio', 'resolution', 'labels'): + options.append(optionsMap[k]) + elif self.section.TYPE == 'show': + if ITEM_TYPE == 'episode': + for k in ('year', 'collection', 'resolution'): + options.append(optionsMap[k]) + elif ITEM_TYPE == 'album': + for k in ('genre', 'year', 'decade', 'collection', 'labels'): + options.append(optionsMap[k]) + else: + for k in ('year', 'genre', 'contentRating', 'network', 'collection', 'actor', 'labels'): + options.append(optionsMap[k]) + elif self.section.TYPE == 'artist': + for k in ('genre', 'country', 'collection'): + options.append(optionsMap[k]) + elif self.section.TYPE == 'photo': + for k in ('year', 'make', 'model', 'aperture', 'exposure', 'iso', 'lens', 'labels'): + options.append(optionsMap[k]) + + result = dropdown.showDropdown(options, (980, 106), with_indicator=True, suboption_callback=self.subOptionCallback) + if not result: + return + + choice = result['type'] + + if choice == 'clear_filter': + self.filter = None + elif choice == 'unwatched': + self.filterUnwatched = not self.filterUnwatched + self.librarySettings.setSetting('filter.unwatched', self.filterUnwatched) + else: + self.filter = result + + self.updateFilterDisplay() + + if self.filter or choice in ('clear_filter', 'unwatched'): + self.fill() + + def clearFilters(self): + self.filter = None + self.filterUnwatched = False + self.librarySettings.setSetting('filter.unwatched', self.filterUnwatched) + self.updateFilterDisplay() + + def resetSort(self): + self.sort = 'titleSort' + self.sortDesc = False + + self.librarySettings.setSetting('sort', self.sort) + self.librarySettings.setSetting('sort.desc', self.sortDesc) + + util.setGlobalProperty('sort', self.sort) + self.setProperty('sort.display', SORT_KEYS[self.section.TYPE].get(self.sort, SORT_KEYS['movie'].get(self.sort))['title']) + + def updateFilterDisplay(self): + if self.filter: + disp = self.filter['display'] + if self.filter.get('sub'): + disp = u'{0}: {1}'.format(disp, self.filter['sub']['display']) + self.setProperty('filter1.display', disp) + self.setProperty('filter2.display', self.filterUnwatched and T(32368, 'Unplayed') or '') + else: + self.setProperty('filter2.display', '') + self.setProperty('filter1.display', self.filterUnwatched and T(32368, 'Unplayed') or T(32345, 'All')) + + def showPanelClicked(self): + mli = self.showPanelControl.getSelectedItem() + if not mli or not mli.dataSource: + return + + sectionType = self.section.TYPE + + updateUnwatchedAndProgress = False + + if mli.dataSource.TYPE == 'collection': + prevItemType = self.librarySettings.getItemType() + self.processCommand(opener.open(mli.dataSource)) + self.librarySettings.setItemType(prevItemType) + elif self.section.TYPE == 'show' or mli.dataSource.TYPE == 'show' or mli.dataSource.TYPE == 'season' or mli.dataSource.TYPE == 'episode': + if ITEM_TYPE == 'episode' or mli.dataSource.TYPE == 'episode' or mli.dataSource.TYPE == 'season': + self.openItem(mli.dataSource) + else: + self.processCommand(opener.handleOpen(subitems.ShowWindow, media_item=mli.dataSource, parent_list=self.showPanelControl)) + if mli.dataSource.TYPE != 'season': # NOTE: A collection with Seasons doesn't have the leafCount/viewedLeafCount until you actually go into the season so we can't update the unwatched count here + updateUnwatchedAndProgress = True + elif self.section.TYPE == 'movie' or mli.dataSource.TYPE == 'movie': + datasource = mli.dataSource + if datasource.isDirectory(): + cls = self.section.__class__ + section = cls(self.section.data, self.section.initpath, self.section.server, self.section.container) + sectionId = section.key + if not sectionId.isdigit(): + sectionId = section.getLibrarySectionId() + + section.set('librarySectionID', sectionId) + section.key = datasource.key + section.title = datasource.title + + self.processCommand(opener.handleOpen(LibraryWindow, windows=self._windows, default_window=self._next, section=section, filter_=self.filter, subDir=True)) + self.librarySettings.setItemType(self.librarySettings.getItemType()) + else: + self.processCommand(opener.handleOpen(preplay.PrePlayWindow, video=datasource, parent_list=self.showPanelControl)) + updateUnwatchedAndProgress = True + elif self.section.TYPE == 'artist' or mli.dataSource.TYPE == 'artist' or mli.dataSource.TYPE == 'album' or mli.dataSource.TYPE == 'track': + if ITEM_TYPE == 'album' or mli.dataSource.TYPE == 'album' or mli.dataSource.TYPE == 'track': + self.openItem(mli.dataSource) + else: + self.processCommand(opener.handleOpen(subitems.ArtistWindow, media_item=mli.dataSource, parent_list=self.showPanelControl)) + elif self.section.TYPE in ('photo', 'photodirectory'): + self.showPhoto(mli.dataSource) + + if not mli: + return + + if mli.dataSource and not mli.dataSource.exists(): + self.showPanelControl.removeItem(mli.pos()) + return + + if updateUnwatchedAndProgress: + self.updateUnwatchedAndProgress(mli) + + def showPhoto(self, photo): + if isinstance(photo, plexnet.photo.Photo) or photo.TYPE == 'clip': + self.processCommand(opener.open(photo)) + else: + self.processCommand(opener.sectionClicked(photo)) + + def updateUnwatchedAndProgress(self, mli): + mli.dataSource.reload() + if mli.dataSource.isWatched: + mli.setProperty('unwatched', '') + mli.setProperty('unwatched.count', '') + else: + if self.section.TYPE == 'show' or mli.dataSource.TYPE == 'show' or mli.dataSource.TYPE == 'season': + mli.setProperty('unwatched.count', str(mli.dataSource.unViewedLeafCount)) + else: + mli.setProperty('unwatched', '1') + mli.setProperty('progress', util.getProgressImage(mli.dataSource)) + + def setTitle(self): + if self.section.TYPE == 'artist': + self.setProperty('screen.title', T(32394, 'MUSIC').upper()) + elif self.section.TYPE in ('photo', 'photodirectory'): + self.setProperty('screen.title', T(32349, 'photos').upper()) + elif self.section.TYPE == 'collection': + self.setProperty('screen.title', T(32382, 'COLLECTION').upper()) + else: + self.setProperty('screen.title', self.section.TYPE == 'show' and T(32393, 'TV SHOWS').upper() or T(32348, 'movies').upper()) + + self.updateFilterDisplay() + + def updateItem(self, mli=None): + mli = mli or self.showPanelControl.getSelectedItem() + if not mli or mli.dataSource: + return + + for task in self.tasks: + if task.contains(mli.pos()): + util.DEBUG_LOG('Moving task to front: {0}'.format(task)) + backgroundthread.BGThreader.moveToFront(task) + break + + def setBackground(self, items, position, randomize=True): + if self.backgroundSet: + return + + if randomize: + item = random.choice(items) + self.updateBackgroundFrom(item) + else: + # we want the first item of the first chunk + if position != 0: + return + + self.updateBackgroundFrom(items[0]) + self.backgroundSet = True + + def fill(self): + if self.chunkMode: + self.chunkMode.reset() + + self.backgroundSet = False + + if self.section.TYPE in ('photo', 'photodirectory'): + self.fillPhotos() + else: + self.fillShows() + + def getFilterOpts(self): + if not self.filter: + return None + + if not self.filter.get('sub'): + util.DEBUG_LOG('Filter missing sub-filter data') + return None + + return (self.filter['type'], six.moves.urllib.parse.unquote_plus(self.filter['sub']['val'])) + + def getSortOpts(self): + if not self.sort: + return None + + return (self.sort, self.sortDesc and 'desc' or 'asc') + + @busy.dialog() + def fillShows(self): + self.setBoolProperty('no.content', False) + self.setBoolProperty('no.content.filtered', False) + items = [] + jitems = [] + self.keyItems = {} + self.firstOfKeyItems = {} + totalSize = 0 + + type_ = None + if ITEM_TYPE == 'episode': + type_ = 4 + elif ITEM_TYPE == 'album': + type_ = 9 + elif ITEM_TYPE == 'collection': + type_ = 18 + + idx = 0 + fallback = 'script.plex/thumb_fallbacks/{0}.png'.format(TYPE_KEYS.get(self.section.type, TYPE_KEYS['movie'])['fallback']) + + if self.sort != 'titleSort' or ITEM_TYPE == 'folder' or self.subDir or self.section.TYPE == "collection": + if ITEM_TYPE == 'folder': + sectionAll = self.section.folder(0, 0, self.subDir) + else: + sectionAll = self.section.all(0, 0, filter_=self.getFilterOpts(), sort=self.getSortOpts(), unwatched=self.filterUnwatched, type_=type_) + + totalSize = sectionAll.totalSize.asInt() + + if not totalSize: + self.showPanelControl.reset() + self.keyListControl.reset() + + if self.filter or self.filterUnwatched: + self.setBoolProperty('no.content.filtered', True) + else: + self.setBoolProperty('no.content', True) + else: + if not self.chunkMode: + for x in range(totalSize): + mli = kodigui.ManagedListItem('') + mli.setProperty('thumb.fallback', fallback) + mli.setProperty('index', str(x)) + items.append(mli) + else: + jumpList = self.section.jumpList(filter_=self.getFilterOpts(), sort=self.getSortOpts(), unwatched=self.filterUnwatched, type_=type_) + + if not jumpList: + self.showPanelControl.reset() + self.keyListControl.reset() + + if self.filter or self.filterUnwatched: + self.setBoolProperty('no.content.filtered', True) + else: + self.setBoolProperty('no.content', True) + + if jumpList is None: + util.messageDialog("Error", "There was an error.") + + return + + for kidx, ji in enumerate(jumpList): + mli = kodigui.ManagedListItem(ji.title, data_source=ji.key) + mli.setProperty('key', ji.key) + mli.setProperty('original', '{0:02d}'.format(kidx)) + self.keyItems[ji.key] = mli + jitems.append(mli) + totalSize += ji.size.asInt() + + if self.chunkMode: + self.chunkMode.addKeyRange(ji.key, (idx, (idx + ji.size.asInt()) - 1)) + idx += ji.size.asInt() + else: + for x in range(ji.size.asInt()): + mli = kodigui.ManagedListItem('') + mli.setProperty('key', ji.key) + mli.setProperty('thumb.fallback', fallback) + mli.setProperty('index', str(idx)) + items.append(mli) + if not x: # i.e. first item + self.firstOfKeyItems[ji.key] = mli + idx += 1 + + util.setGlobalProperty('key', jumpList[0].key) + + if self.scrollBar: + self.scrollBar.setSizeAndCount(totalSize, 12) + + if self.chunkMode: + self.chunkMode.itemCount = totalSize + items = [ + kodigui.ManagedListItem('', properties={'index': str(i)}) for i in range(CHUNK_SIZE * 2) + ] + [ + kodigui.ManagedListItem('') for i in range(CHUNK_SIZE) + ] + + self.showPanelControl.reset() + self.keyListControl.reset() + + self.showPanelControl.addItems(items) + self.keyListControl.addItems(jitems) + + self.showPanelControl.selectItem(0) + self.setFocusId(self.POSTERS_PANEL_ID) + + tasks = [] + ct = 0 + for start in range(0, totalSize, CHUNK_SIZE): + tasks.append( + ChunkRequestTask().setup( + self.section, start, CHUNK_SIZE, self.chunkCallback, filter_=self.getFilterOpts(), sort=self.getSortOpts(), unwatched=self.filterUnwatched, subDir=self.subDir + ) + ) + ct += 1 + + if self.chunkMode and ct > 1: + break + + self.tasks.add(tasks) + backgroundthread.BGThreader.addTasksToFront(tasks) + + def showPhotoItemProperties(self, photo): + if photo.isFullObject(): + return + + task = PhotoPropertiesTask().setup(photo, self._showPhotoItemProperties) + self.tasks.add(task) + backgroundthread.BGThreader.addTasksToFront([task]) + + def _showPhotoItemProperties(self, photo): + mli = self.showPanelControl.getSelectedItem() + if not mli or not mli.dataSource.TYPE == 'photo': + for mli in self.showPanelControl: + if mli.dataSource == photo: + break + else: + return + + mli.setProperty('camera.model', photo.media[0].model) + mli.setProperty('camera.lens', photo.media[0].lens) + + attrib = [] + if photo.media[0].height: + attrib.append(u'{0} x {1}'.format(photo.media[0].width, photo.media[0].height)) + + orientation = photo.media[0].parts[0].orientation + if orientation: + attrib.append(u'{0} Mo'.format(orientation)) + + container = photo.media[0].container_ or os.path.splitext(photo.media[0].parts[0].file)[-1][1:].lower() + if container == 'jpg': + container = 'jpeg' + attrib.append(container.upper()) + if attrib: + mli.setProperty('photo.dims', u' \u2022 '.join(attrib)) + + settings = [] + if photo.media[0].iso: + settings.append('ISO {0}'.format(photo.media[0].iso)) + if photo.media[0].aperture: + settings.append('{0}'.format(photo.media[0].aperture)) + if photo.media[0].exposure: + settings.append('{0}'.format(photo.media[0].exposure)) + mli.setProperty('camera.settings', u' \u2022 '.join(settings)) + mli.setProperty('photo.summary', photo.get('summary')) + + @busy.dialog() + def fillPhotos(self): + self.setBoolProperty('no.content', False) + self.setBoolProperty('no.content.filtered', False) + items = [] + keys = [] + self.firstOfKeyItems = {} + idx = 0 + + if self.section.TYPE == 'photodirectory': + photos = self.section.all() + else: + photos = self.section.all(filter_=self.getFilterOpts(), sort=self.getSortOpts(), unwatched=self.filterUnwatched) + + if not photos: + return + + photo = random.choice(photos) + self.updateBackgroundFrom(photo) + thumbDim = TYPE_KEYS.get(self.section.type, TYPE_KEYS['movie'])['thumb_dim'] + fallback = 'script.plex/thumb_fallbacks/{0}.png'.format(TYPE_KEYS.get(self.section.type, TYPE_KEYS['movie'])['fallback']) + + if not photos: + if self.filter or self.filterUnwatched: + self.setBoolProperty('no.content.filtered', True) + else: + self.setBoolProperty('no.content', True) + return + + for photo in photos: + title = photo.title + if photo.TYPE == 'photodirectory': + thumb = photo.composite.asTranscodedImageURL(*thumbDim) + mli = kodigui.ManagedListItem(title, thumbnailImage=thumb, data_source=photo) + mli.setProperty('is.folder', '1') + else: + thumb = photo.defaultThumb.asTranscodedImageURL(*thumbDim) + label2 = util.cleanLeadingZeros(photo.originallyAvailableAt.asDatetime('%d %B %Y')) + mli = kodigui.ManagedListItem(title, label2, thumbnailImage=thumb, data_source=photo) + + mli.setProperty('thumb.fallback', fallback) + mli.setProperty('index', str(idx)) + + key = title[0].upper() + if key not in KEYS: + key = '#' + if key not in keys: + self.firstOfKeyItems[key] = mli + keys.append(key) + mli.setProperty('key', str(key)) + items.append(mli) + idx += 1 + + litems = [] + self.keyItems = {} + for i, key in enumerate(keys): + mli = kodigui.ManagedListItem(key, data_source=key) + mli.setProperty('key', key) + mli.setProperty('original', '{0:02d}'.format(i)) + self.keyItems[key] = mli + litems.append(mli) + + self.showPanelControl.reset() + self.keyListControl.reset() + + self.showPanelControl.addItems(items) + self.keyListControl.addItems(litems) + + if keys: + util.setGlobalProperty('key', keys[0]) + + def chunkCallback(self, items, start, clear=False): + if clear: + with self.lock: + items = [kodigui.ManagedListItem('') for i in range(CHUNK_SIZE * 3)] + + self.showPanelControl.reset() + self.showPanelControl.addItems(items) + + self.cleared = True + return + + if self.cleared: + self.cleared = False + busy.widthDialog(self._chunkCallback, self, items, start) + else: + self._chunkCallback(items, start) + + def _chunkCallback(self, items, start): + if self.chunkMode and not self.chunkMode.posIsValid(start): + return + + with self.lock: + if self.chunkMode and not self.chunkMode.posIsValid(start): + return + pos = start + self.setBackground(items, pos, randomize=not util.advancedSettings.dynamicBackgrounds) + + thumbDim = TYPE_KEYS.get(self.section.type, TYPE_KEYS['movie'])['thumb_dim'] + artDim = TYPE_KEYS.get(self.section.type, TYPE_KEYS['movie']).get('art_dim', (256, 256)) + + showUnwatched = False + if (self.section.TYPE in ('movie', 'show') and items[0].TYPE != 'collection') or (self.section.TYPE == 'collection' and items[0].TYPE in ('movie', 'show', 'episode')): # NOTE: A collection with Seasons doesn't have the leafCount/viewedLeafCount until you actually go into the season so we can't update the unwatched count here + showUnwatched = True + + if self.chunkMode and len(items) < CHUNK_SIZE: + items += [None] * (CHUNK_SIZE - len(items)) + + if ITEM_TYPE == 'episode': + for offset, obj in enumerate(items): + mli = self.showPanelControl[pos] + if obj: + mli.dataSource = obj + mli.setProperty('index', str(pos)) + if obj.index: + subtitle = u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), obj.parentIndex, T(32311, 'E'), obj.index) + mli.setProperty('subtitle', subtitle) + subtitle = "\n" + subtitle + else: + subtitle = ' - ' + obj.originallyAvailableAt.asDatetime('%m/%d/%y') + mli.setLabel((obj.defaultTitle or '') + subtitle) + + mli.setThumbnailImage(obj.defaultThumb.asTranscodedImageURL(*thumbDim)) + + mli.setProperty('summary', obj.summary) + + # # mli.setProperty('key', self.chunkMode.getKey(pos)) + + mli.setLabel2(util.durationToText(obj.fixedDuration())) + mli.setProperty('art', obj.defaultArt.asTranscodedImageURL(*artDim)) + if not obj.isWatched: + mli.setProperty('unwatched', '1') + else: + mli.clear() + if obj is False: + mli.setProperty('index', str(pos)) + else: + mli.setProperty('index', '') + + pos += 1 + + elif ITEM_TYPE == 'album': + for offset, obj in enumerate(items): + mli = self.showPanelControl[pos] + if obj: + mli.dataSource = obj + mli.setProperty('index', str(pos)) + mli.setLabel(u'{0}\n{1}'.format(obj.parentTitle, obj.title)) + + mli.setThumbnailImage(obj.defaultThumb.asTranscodedImageURL(*thumbDim)) + + mli.setProperty('summary', obj.summary) + + # # mli.setProperty('key', self.chunkMode.getKey(pos)) + + mli.setLabel2(obj.year) + + if self.chunkMode: + mli.setProperty('key', self.chunkMode.getKey(pos)) + + else: + mli.clear() + if obj is False: + mli.setProperty('index', str(pos)) + else: + mli.setProperty('index', '') + + pos += 1 + else: + for offset, obj in enumerate(items): + mli = self.showPanelControl[pos] + if obj: + mli.setProperty('index', str(pos)) + mli.setLabel(obj.defaultTitle or '') + mli.setThumbnailImage(obj.defaultThumb.asTranscodedImageURL(*thumbDim)) + mli.dataSource = obj + mli.setProperty('summary', obj.get('summary')) + + #if self.chunkMode: + # mli.setProperty('key', self.chunkMode.getKey(pos)) + #else: + # mli.setProperty('key', obj.key) + + if showUnwatched and obj.TYPE != 'collection': + if not obj.isDirectory(): + mli.setLabel2(util.durationToText(obj.fixedDuration())) + mli.setProperty('art', obj.defaultArt.asTranscodedImageURL(*artDim)) + if not obj.isWatched and obj.TYPE != "Directory": + if self.section.TYPE == 'show' or obj.TYPE == 'show' or obj.TYPE == 'season': + mli.setProperty('unwatched.count', str(obj.unViewedLeafCount)) + else: + mli.setProperty('unwatched', '1') + + mli.setProperty('progress', util.getProgressImage(obj)) + else: + mli.clear() + if obj is False: + mli.setProperty('index', str(pos)) + else: + mli.setProperty('index', '') + + pos += 1 + + +class PostersWindow(kodigui.ControlledWindow): + xmlFile = 'script-plex-posters.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + POSTERS_PANEL_ID = 101 + KEY_LIST_ID = 151 + SCROLLBAR_ID = 152 + + OPTIONS_GROUP_ID = 200 + + HOME_BUTTON_ID = 201 + SEARCH_BUTTON_ID = 202 + PLAYER_STATUS_BUTTON_ID = 204 + + SORT_BUTTON_ID = 210 + FILTER1_BUTTON_ID = 211 + FILTER2_BUTTON_ID = 212 + ITEM_TYPE_BUTTON_ID = 312 + + PLAY_BUTTON_ID = 301 + SHUFFLE_BUTTON_ID = 302 + OPTIONS_BUTTON_ID = 303 + VIEWTYPE_BUTTON_ID = 304 + + VIEWTYPE = 'panel' + MULTI_WINDOW_ID = 0 + + CUSTOM_SCOLLBAR_BUTTON_ID = 951 + + +class PostersSmallWindow(PostersWindow): + xmlFile = 'script-plex-posters-small.xml' + VIEWTYPE = 'panel2' + MULTI_WINDOW_ID = 1 + + +class PostersChunkedWindow(PostersWindow): + xmlFile = 'script-plex-listview-16x9-chunked.xml' + VIEWTYPE = 'list' + MULTI_WINDOW_ID = 0 + + +class ListView16x9Window(PostersWindow): + xmlFile = 'script-plex-listview-16x9.xml' + VIEWTYPE = 'list' + MULTI_WINDOW_ID = 2 + + +class ListView16x9ChunkedWindow(PostersWindow): + xmlFile = 'script-plex-listview-16x9-chunked.xml' + VIEWTYPE = 'list' + MULTI_WINDOW_ID = 1 + + +class SquaresWindow(PostersWindow): + xmlFile = 'script-plex-squares.xml' + VIEWTYPE = 'panel' + MULTI_WINDOW_ID = 0 + + +class SquaresChunkedWindow(PostersWindow): + xmlFile = 'script-plex-listview-square-chunked.xml' + VIEWTYPE = 'list' + MULTI_WINDOW_ID = 0 + + +class ListViewSquareWindow(PostersWindow): + xmlFile = 'script-plex-listview-square.xml' + VIEWTYPE = 'list' + MULTI_WINDOW_ID = 1 + + +class ListViewSquareChunkedWindow(PostersWindow): + xmlFile = 'script-plex-listview-square-chunked.xml' + VIEWTYPE = 'list' + MULTI_WINDOW_ID = 1 + + +VIEWS_POSTER = { + 'panel': PostersWindow, + 'panel2': PostersSmallWindow, + 'list': ListView16x9Window, + 'all': (PostersWindow, PostersSmallWindow, ListView16x9Window) +} + +VIEWS_POSTER_CHUNKED = { + 'panel': PostersChunkedWindow, + 'list': ListView16x9ChunkedWindow, + 'all': (PostersChunkedWindow, ListView16x9ChunkedWindow) +} + +VIEWS_SQUARE = { + 'panel': SquaresWindow, + 'list': ListViewSquareWindow, + 'all': (SquaresWindow, ListViewSquareWindow) +} + +VIEWS_SQUARE_CHUNKED = { + 'panel': SquaresChunkedWindow, + 'list': ListViewSquareChunkedWindow, + 'all': (SquaresChunkedWindow, ListViewSquareChunkedWindow) +} \ No newline at end of file diff --git a/script.plexmod/lib/windows/mixins.py b/script.plexmod/lib/windows/mixins.py new file mode 100644 index 000000000..1e3586847 --- /dev/null +++ b/script.plexmod/lib/windows/mixins.py @@ -0,0 +1,61 @@ +# coding=utf-8 + +from lib import util + +from . import kodigui + + +class SeasonsMixin(): + SEASONS_CONTROL_ATTR = "subItemListControl" + + THUMB_DIMS = { + 'show': { + 'main.thumb': util.scaleResolution(347, 518), + 'item.thumb': util.scaleResolution(174, 260) + }, + 'episode': { + 'main.thumb': util.scaleResolution(347, 518), + 'item.thumb': util.scaleResolution(198, 295) + }, + 'artist': { + 'main.thumb': util.scaleResolution(519, 519), + 'item.thumb': util.scaleResolution(215, 215) + } + } + + def _createListItem(self, mediaItem, obj): + mli = kodigui.ManagedListItem( + obj.title or '', + thumbnailImage=obj.defaultThumb.asTranscodedImageURL(*self.THUMB_DIMS[mediaItem.type]['item.thumb']), + data_source=obj + ) + return mli + + def fillSeasons(self, mediaItem, update=False, seasonsFilter=None, selectSeason=None): + seasons = mediaItem.seasons() + if not seasons or (seasonsFilter and not seasonsFilter(seasons)): + return False + + items = [] + idx = 0 + for season in seasons: + if selectSeason and season == selectSeason: + continue + + mli = self._createListItem(mediaItem, season) + if mli: + mli.setProperty('index', str(idx)) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/show.png') + mli.setProperty('unwatched.count', not season.isWatched and str(season.unViewedLeafCount) or '') + items.append(mli) + idx += 1 + + subItemListControl = getattr(self, self.SEASONS_CONTROL_ATTR) + if update: + subItemListControl.replaceItems(items) + else: + subItemListControl.reset() + subItemListControl.addItems(items) + + return True + diff --git a/script.plexmod/lib/windows/musicplayer.py b/script.plexmod/lib/windows/musicplayer.py new file mode 100644 index 000000000..d2277eb5e --- /dev/null +++ b/script.plexmod/lib/windows/musicplayer.py @@ -0,0 +1,169 @@ +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui +from . import currentplaylist +from . import opener + +from lib import player +from lib import util + + +def timeDisplay(ms): + h = ms / 3600000 + m = (ms % 3600000) / 60000 + s = (ms % 60000) / 1000 + return '{0:0>2}:{1:0>2}:{2:0>2}'.format(h, m, s) + + +def simplifiedTimeDisplay(ms): + left, right = timeDisplay(ms).rsplit(':', 1) + left = left.lstrip('0:') or '0' + return left + ':' + right + + +class MusicPlayerWindow(currentplaylist.CurrentPlaylistWindow): + xmlFile = 'script-plex-music_player.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + SEEK_BUTTON_ID = 100 + SEEK_IMAGE_ID = 200 + SHUFFLE_REMOTE_BUTTON_ID = 422 + REPEAT_BUTTON_ID = 401 + SKIP_PREV_BUTTON_ID = 404 + SKIP_NEXT_BUTTON_ID = 409 + STOP_BUTTON_ID = 407 + + SEEK_IMAGE_WIDTH = 1920 + + BAR_RIGHT = 1920 + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + self.track = kwargs.get('track') + self.playlist = kwargs.get('playlist') + self.album = kwargs.get('album') + self.selectedOffset = 0 + self.exitCommand = None + + if self.track: + self.duration = self.track.duration.asInt() + else: + self.setDuration() + + def onFirstInit(self): + if self.playlist and self.playlist.isRemote: + self.playlist.on('change', self.updateProperties) + self.setupSeekbar() + self.selectionBoxMax = self.SEEK_IMAGE_WIDTH - (self.selectionBoxHalf - 3) + + self.updateProperties() + self.play() + self.setFocusId(406) + + def doClose(self, **kwargs): + player.PLAYER.off('playback.started', self.onPlayBackStarted) + if self.playlist and self.playlist.isRemote: + self.playlist.off('change', self.updateProperties) + kodigui.ControlledWindow.doClose(self) + + def onAction(self, action): + try: + if action == xbmcgui.ACTION_STOP: + self.stopButtonClicked() + return + except: + util.ERROR() + + super().onAction(action) + + def onClick(self, controlID): + if controlID == self.PLAYLIST_BUTTON_ID: + self.showPlaylist() + elif controlID == self.SEEK_BUTTON_ID: + self.seekButtonClicked() + elif controlID == self.SHUFFLE_REMOTE_BUTTON_ID: + self.playlist.setShuffle() + elif controlID == self.REPEAT_BUTTON_ID: + self.repeatButtonClicked() + elif controlID == self.SKIP_PREV_BUTTON_ID: + self.skipPrevButtonClicked() + elif controlID == self.SKIP_NEXT_BUTTON_ID: + self.skipNextButtonClicked() + elif controlID == self.OPTIONS_BUTTON_ID: + self.optionsButtonClicked((1240, 1060)) + elif controlID == self.STOP_BUTTON_ID: + self.stopButtonClicked() + + def repeatButtonClicked(self): + if self.playlist and self.playlist.isRemote: + if xbmc.getCondVisibility('Playlist.IsRepeatOne'): + xbmc.executebuiltin('PlayerControl(RepeatOff)') + elif self.playlist.isRepeat: + self.playlist.setRepeat(False) + self.playlist.refresh(force=True) + xbmc.executebuiltin('PlayerControl(RepeatOne)') + else: + self.playlist.setRepeat(True) + self.playlist.refresh(force=True) + else: + xbmc.executebuiltin('PlayerControl(Repeat)') + + def skipPrevButtonClicked(self): + if not xbmc.getCondVisibility('MusicPlayer.HasPrevious') and self.playlist and self.playlist.isRemote: + util.DEBUG_LOG('MusicPlayer: No previous in Kodi playlist - refreshing remote PQ') + if not self.playlist.refresh(force=True, wait=True): + return + + xbmc.executebuiltin('PlayerControl(Previous)') + + def skipNextButtonClicked(self): + if not xbmc.getCondVisibility('MusicPlayer.HasNext') and self.playlist and self.playlist.isRemote: + util.DEBUG_LOG('MusicPlayer: No next in Kodi playlist - refreshing remote PQ') + if not self.playlist.refresh(force=True, wait=True): + return + + xbmc.executebuiltin('PlayerControl(Next)') + + def showPlaylist(self): + self.processCommand(opener.handleOpen(currentplaylist.CurrentPlaylistWindow, winID=xbmcgui.getCurrentWindowId())) + + def stopButtonClicked(self): + self.doClose() + + def updateProperties(self, **kwargs): + if self.playlist: + if self.playlist.isRemote: + self.setProperty('pq.isRemote', '1') + self.setProperty('pq.hasnext', self.playlist.allowSkipNext and '1' or '') + self.setProperty('pq.hasprev', self.playlist.allowSkipPrev and '1' or '') + self.setProperty('pq.repeat', self.playlist.isRepeat and '1' or '') + self.setProperty('pq.shuffled', self.playlist.isShuffled and '1' or '') + else: + self.setProperties(('pq.isRemote', 'pq.hasnext', 'pq.hasprev', 'pq.repeat', 'pq.shuffled'), '') + + def play(self): + if not self.track: + return + + if util.trackIsPlaying(self.track): + return + + fanart = None + if self.playlist: + fanart = self.playlist.get('composite') or self.playlist.defaultArt + # player.PLAYER.playAudio(self.track, fanart=self.getProperty('background')) + if self.album: + index = 0 + for i, track in enumerate(self.album.tracks()): + if track == self.track: + index = i + player.PLAYER.playAlbum(self.album, startpos=index, fanart=fanart) + elif self.playlist: + player.PLAYER.playAudioPlaylist(self.playlist, startpos=list(self.playlist.items()).index(self.track), fanart=fanart) + else: + player.PLAYER.playAudio(self.track) diff --git a/script.plexmod/lib/windows/opener.py b/script.plexmod/lib/windows/opener.py new file mode 100644 index 000000000..4aed06b2d --- /dev/null +++ b/script.plexmod/lib/windows/opener.py @@ -0,0 +1,169 @@ +from __future__ import absolute_import +from . import busy + +from plexnet import playqueue, plexapp, plexlibrary +from lib import util +import six + + +def open(obj, **kwargs): + if isinstance(obj, playqueue.PlayQueue): + if busy.widthDialog(obj.waitForInitialization, None): + if obj.type == 'audio': + from . import musicplayer + return handleOpen(musicplayer.MusicPlayerWindow, track=obj.current(), playlist=obj) + elif obj.type == 'photo': + from . import photos + return handleOpen(photos.PhotoWindow, play_queue=obj) + else: + from . import videoplayer + videoplayer.play(play_queue=obj) + return '' + elif isinstance(obj, six.string_types): + key = obj + if not obj.startswith('/'): + key = '/library/metadata/{0}'.format(obj) + return open(plexapp.SERVERMANAGER.selectedServer.getObject(key), **kwargs) + elif obj.TYPE == 'episode': + return episodeClicked(obj, **kwargs) + elif obj.TYPE == 'movie': + return playableClicked(obj, **kwargs) + elif obj.TYPE in ('show'): + return showClicked(obj, **kwargs) + elif obj.TYPE in ('artist'): + return artistClicked(obj, **kwargs) + elif obj.TYPE in ('season'): + return seasonClicked(obj, **kwargs) + elif obj.TYPE in ('album'): + return albumClicked(obj, **kwargs) + elif obj.TYPE in ('photo',): + return photoClicked(obj, **kwargs) + elif obj.TYPE in ('photodirectory'): + return photoDirectoryClicked(obj, **kwargs) + elif obj.TYPE in ('track'): + return trackClicked(obj, **kwargs) + elif obj.TYPE in ('playlist'): + return playlistClicked(obj, **kwargs) + elif obj.TYPE in ('clip'): + from . import videoplayer + return videoplayer.play(video=obj) + elif obj.TYPE in ('collection'): + return collectionClicked(obj, **kwargs) + elif obj.TYPE in ('Genre'): + return genreClicked(obj, **kwargs) + elif obj.TYPE in ('Director'): + return directorClicked(obj, **kwargs) + elif obj.TYPE in ('Role'): + return actorClicked(obj, **kwargs) + + +def handleOpen(winclass, **kwargs): + w = None + try: + autoPlay = kwargs.pop("auto_play", False) + if autoPlay and hasattr(winclass, "doAutoPlay"): + w = winclass.create(show=False, **kwargs) + if w.doAutoPlay(): + w.modal() + else: + w = winclass.open(**kwargs) + return w.exitCommand or '' + except AttributeError: + pass + except util.NoDataException: + raise + finally: + del w + util.garbageCollect() + + return '' + + +def playableClicked(playable, **kwargs): + from . import preplay + return handleOpen(preplay.PrePlayWindow, video=playable, **kwargs) + + +def episodeClicked(episode, **kwargs): + from . import episodes + return handleOpen(episodes.EpisodesWindow, episode=episode, **kwargs) + + +def showClicked(show, **kwargs): + from . import subitems + return handleOpen(subitems.ShowWindow, media_item=show, **kwargs) + + +def artistClicked(artist, **kwargs): + from . import subitems + return handleOpen(subitems.ArtistWindow, media_item=artist, **kwargs) + + +def seasonClicked(season, **kwargs): + from . import episodes + return handleOpen(episodes.EpisodesWindow, season=season, **kwargs) + + +def albumClicked(album, **kwargs): + from . import tracks + return handleOpen(tracks.AlbumWindow, album=album, **kwargs) + + +def photoClicked(photo, **kwargs): + from . import photos + return handleOpen(photos.PhotoWindow, photo=photo, **kwargs) + + +def trackClicked(track, **kwargs): + from . import musicplayer + return handleOpen(musicplayer.MusicPlayerWindow, track=track, **kwargs) + + +def photoDirectoryClicked(photodirectory, **kwargs): + return sectionClicked(photodirectory, **kwargs) + + +def playlistClicked(pl, **kwargs): + from . import playlist + return handleOpen(playlist.PlaylistWindow, playlist=pl, **kwargs) + + +def collectionClicked(collection, **kwargs): + return sectionClicked(collection, **kwargs) + + +def sectionClicked(section, filter_=None, **kwargs): + from . import library + library.ITEM_TYPE = section.TYPE + key = section.key + if not key.isdigit(): + key = section.getLibrarySectionId() + viewtype = util.getSetting('viewtype.{0}.{1}'.format(section.server.uuid, key)) + if section.TYPE in ('artist', 'photo', 'photodirectory'): + default = library.VIEWS_SQUARE.get(viewtype) + return handleOpen( + library.LibraryWindow, windows=library.VIEWS_SQUARE.get('all'), default_window=default, section=section, filter_=filter_, **kwargs + ) + else: + default = library.VIEWS_POSTER.get(viewtype) + return handleOpen( + library.LibraryWindow, windows=library.VIEWS_POSTER.get('all'), default_window=default, section=section, filter_=filter_, **kwargs + ) + + +def genreClicked(genre, **kwargs): + section = plexlibrary.LibrarySection.fromFilter(genre) + filter_ = {'type': genre.FILTER, 'display': 'Genre', 'sub': {'val': genre.id, 'display': genre.tag}} + return sectionClicked(section, filter_, **kwargs) + + +def directorClicked(director, **kwargs): + section = plexlibrary.LibrarySection.fromFilter(director) + filter_ = {'type': director.FILTER, 'display': 'Director', 'sub': {'val': director.id, 'display': director.tag}} + return sectionClicked(section, filter_, **kwargs) + + +def actorClicked(actor, **kwargs): + section = plexlibrary.LibrarySection.fromFilter(actor) + filter_ = {'type': actor.FILTER, 'display': 'Actor', 'sub': {'val': actor.id, 'display': actor.tag}} + return sectionClicked(section, filter_, ignoreLibrarySettings=True, **kwargs) diff --git a/script.plexmod/lib/windows/optionsdialog.py b/script.plexmod/lib/windows/optionsdialog.py new file mode 100644 index 000000000..dbbd0961e --- /dev/null +++ b/script.plexmod/lib/windows/optionsdialog.py @@ -0,0 +1,55 @@ +from __future__ import absolute_import +from . import kodigui + +from lib import util + + +class OptionsDialog(kodigui.BaseDialog): + xmlFile = 'script-plex-options_dialog.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + GROUP_ID = 100 + BUTTON_IDS = (1001, 1002, 1003) + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + self.header = kwargs.get('header') + self.info = kwargs.get('info') + self.button0 = kwargs.get('button0') + self.button1 = kwargs.get('button1') + self.button2 = kwargs.get('button2') + self.buttonChoice = None + + def onFirstInit(self): + self.setProperty('header', self.header) + self.setProperty('info', self.info) + + if self.button2: + self.setProperty('button.2', self.button2) + + if self.button1: + self.setProperty('button.1', self.button1) + + if self.button0: + self.setProperty('button.0', self.button0) + + self.setBoolProperty('initialized', True) + util.MONITOR.waitForAbort(0.1) + self.setFocusId(self.BUTTON_IDS[0]) + + def onClick(self, controlID): + if controlID in self.BUTTON_IDS: + self.buttonChoice = self.BUTTON_IDS.index(controlID) + self.doClose() + + +def show(header, info, button0=None, button1=None, button2=None): + w = OptionsDialog.open(header=header, info=info, button0=button0, button1=button1, button2=button2) + choice = w.buttonChoice + del w + util.garbageCollect() + return choice diff --git a/script.plexmod/lib/windows/pagination.py b/script.plexmod/lib/windows/pagination.py new file mode 100644 index 000000000..474be481c --- /dev/null +++ b/script.plexmod/lib/windows/pagination.py @@ -0,0 +1,274 @@ +from __future__ import absolute_import +from . import kodigui +from kodi_six import xbmcgui +from lib import util + + +class MCLPaginator(object): + """ + A paginator for ManagedControlList instances + """ + control = None + pageSize = 8 + initialPageSize = 18 # + orphans = 26 + + # the amount of overhang allowed for both sides; don't show pagination when the overhang fits the current item count + orphans = pageSize + offset = 0 + leafCount = None + parentWindow = None + thumbFallback = None + + _direction = None + _currentAmount = None + _lastAmount = None + _boundaryHit = False + + def __init__(self, control, parent_window, page_size=None, orphans=None, leaf_count=None): + self.control = control + self.pageSize = page_size if page_size is not None else self.pageSize + self.orphans = orphans if orphans is not None else self.orphans + self.leafCount = leaf_count + self.parentWindow = parent_window + + self.reset() + + def reset(self): + self.offset = 0 + self._currentAmount = 0 + self._lastAmount = None + self._boundaryHit = False + self._direction = None + + def getData(self, offset, amount): + raise NotImplementedError + + def createListItem(self, data): + return self.parentWindow.createListItem(data) + + def prepareListItem(self, data, mli): + pass + + def readyForPaging(self): + return self.parentWindow.initialized + + @property + def _readyForPaging(self): + return self.readyForPaging() + + @property + def boundaryHit(self): + self._boundaryHit = False + + if not self._readyForPaging: + return + + mli = self.control.getSelectedItem() + if mli and mli.getProperty("is.boundary") and not mli.getProperty("is.updating"): + direction = "left" if mli.getProperty("left.boundary") else "right" + mli.setBoolProperty("is.updating", True) + self.offset = int(mli.getProperty("orig.index")) + self._direction = direction + self._boundaryHit = True + + return self._boundaryHit + + @property + def nextPage(self): + leafCount = self.leafCount + offset = self.offset + amount = self.pageSize + + if self._direction == "left": + if offset <= self.initialPageSize: + # return to initial page + offset = 0 + amount = self.initialPageSize + else: + # move the slice to the left by :amount: based on :offset: + amount = min(offset, self.pageSize) + offset -= amount + + # avoid short pages on the left end + if 0 < offset < self.orphans: + amount += offset + offset = 0 + + else: + # move the slice to the right + itemsLeft = leafCount - offset + # avoid short pages on the right end + if itemsLeft <= self.pageSize + self.orphans: + amount = self.pageSize + self.orphans + + self.offset = offset + data = self.getData(offset, amount) + self._lastAmount = self._currentAmount + self._currentAmount = len(data) + return data + + @property + def initialPage(self): + amount = self.initialPageSize + if self.initialPageSize + self.orphans >= self.leafCount: + amount = self.initialPageSize + self.orphans + + data = self.getData(self.offset, amount) + if data: + self._lastAmount = self._currentAmount + self._currentAmount = len(data) + return data + + def populate(self, items): + """ + Populates the current page to the bound Control List. Adds prev/next MLIs and selects the correct control + after doing so. + :param items: + :return: + """ + idx = 0 + moreLeft = self.offset > 0 + moreRight = self.offset + self._currentAmount < self.leafCount + + finalItems = [] + thumbFallback = self.thumbFallback + + if items: + for item in items: + mli = self.createListItem(item) + + if mli: + mli.setProperty('index', str(idx)) + self.prepareListItem(item, mli) + if thumbFallback: + if callable(thumbFallback): + mli.setProperty('thumb.fallback', thumbFallback(item)) + else: + mli.setProperty('thumb.fallback', thumbFallback) + + finalItems.append(mli) + idx += 1 + + if moreRight: + end = kodigui.ManagedListItem('') + end.setBoolProperty('is.boundary', True) + end.setBoolProperty('right.boundary', True) + end.setProperty("orig.index", str(int(self.offset + self._currentAmount))) + finalItems.append(end) + else: + # no boundary, rightmost item + finalItems[-1].setBoolProperty('last.item', True) + + if moreLeft: + start = kodigui.ManagedListItem('') + start.setBoolProperty('is.boundary', True) + start.setBoolProperty('left.boundary', True) + start.setProperty("orig.index", str(int(self.offset))) + finalItems.insert(0, start) + + self.control.replaceItems(finalItems) + self.selectItem(self._currentAmount, more_left=moreLeft, more_right=moreRight, items=items) + + return finalItems + + def selectItem(self, amount, more_left=False, more_right=False, items=None): + if self._direction: + if self._direction == "left": + self.control.selectItem(amount - (1 if not more_left else 0)) + return True + + elif self._direction == "right": + self.control.selectItem(1) + return True + + def paginate(self, force_page=False): + """ + Triggers the pagination for the currently selected view. In case of a hit boundary, show the next or previous + page, otherwise show the initial page. + :return: + """ + if self._boundaryHit or force_page: + items = self.nextPage + + else: + items = self.initialPage + + return self.populate(items) + + @property + def canSimpleWrap(self): + return self.initialPageSize + self.orphans >= self.leafCount + + def wrap(self, mli, last_mli, action): + """ + Wraps around the list if the first or last item is currently selected and the user requests to round robin. + :param mli: current item + :param last_mli: previous item + :param action: xbmcgui action + :return: + """ + + index = int(mli.getProperty("index")) + last_mli_index = int(last_mli.getProperty("index")) + + # special case for our absolute last item + is_rightmost = action == xbmcgui.ACTION_MOVE_RIGHT \ + and mli.getProperty('last.item') \ + and last_mli.getProperty('last.item') + + # _lastAmount is used to immediately wrap again after a wrap has happened; potentially an issue + if not is_rightmost and last_mli_index not in \ + (0, self._currentAmount - 1, (self._lastAmount - 1) if self._lastAmount else None) \ + or self._currentAmount < 2: + return + + onlyTwo = self._currentAmount == 2 + + items = None + if action == xbmcgui.ACTION_MOVE_LEFT and index == 0: + if onlyTwo and last_mli_index == self._currentAmount - 1: + return + + if not self.canSimpleWrap: + self._direction = "right" + self.offset = self.leafCount - self.orphans - self.pageSize + items = self.paginate(force_page=True) + self.control.selectItem(self._currentAmount) + else: + self.control.selectItem(self.leafCount - 1) + elif action == xbmcgui.ACTION_MOVE_RIGHT and (index == self._currentAmount - 1 or is_rightmost): + if onlyTwo and last_mli_index == 0: + return + + if not self.canSimpleWrap: + self._direction = "left" + self.offset = 0 + items = self.paginate() + self.control.selectItem(0) + + if items: + return items + + +class BaseRelatedPaginator(MCLPaginator): + initialPageSize = 8 + pageSize = initialPageSize + orphans = initialPageSize // 2 + + thumbFallback = lambda self, rel: 'script.plex/thumb_fallbacks/{0}.png'.format( + rel.type in ('show', 'season', 'episode') and 'show' or 'movie') + + def createListItem(self, rel): + return kodigui.ManagedListItem( + rel.title or '', + thumbnailImage=rel.defaultThumb.asTranscodedImageURL(*self.parentWindow.RELATED_DIM), + data_source=rel + ) + + def prepareListItem(self, data, mli): + if data.type in ('season', 'show'): + if not mli.dataSource.isWatched: + mli.setProperty('unwatched.count', str(mli.dataSource.unViewedLeafCount)) + else: + mli.setProperty('unwatched', not mli.dataSource.isWatched and '1' or '') + mli.setProperty('progress', util.getProgressImage(mli.dataSource)) diff --git a/script.plexmod/lib/windows/photos.py b/script.plexmod/lib/windows/photos.py new file mode 100644 index 000000000..df43a7e93 --- /dev/null +++ b/script.plexmod/lib/windows/photos.py @@ -0,0 +1,530 @@ +from __future__ import absolute_import +import threading +import time +import os +import tempfile +import shutil +import hashlib +import requests + +from kodi_six import xbmc, xbmcvfs +from kodi_six import xbmcgui + +from . import kodigui +from . import busy + +from lib import util, colors +from plexnet import plexapp, plexplayer, playqueue + + +class PhotoWindow(kodigui.BaseWindow): + xmlFile = 'script-plex-photo.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + OVERLAY_BUTTON_ID = 250 + OSD_GROUP_ID = 200 + + OSD_BUTTONS_GROUP_ID = 400 + REPEAT_BUTTON_ID = 401 + SHUFFLE_BUTTON_ID = 402 + ROTATE_BUTTON_ID = 403 + PREV_BUTTON_ID = 404 + PLAY_PAUSE_BUTTON_ID = 406 + STOP_BUTTON_ID = 407 + NEXT_BUTTON_ID = 409 + PQUEUE_BUTTON_ID = 412 + + PQUEUE_LIST_ID = 500 + PQUEUE_LIST_OVERLAY_BUTTON_ID = 501 + + SLIDESHOW_INTERVAL = util.slideshowInterval + + PHOTO_STACK_SIZE = 10 + tempSubFolder = ("p4k", "photos") + + def __init__(self, *args, **kwargs): + kodigui.BaseWindow.__init__(self, *args, **kwargs) + self.photo = kwargs.get('photo') + self.playQueue = kwargs.get('play_queue') + self.playerObject = None + self.timelineType = 'photo' + self.lastTimelineState = None + self.ignoreTimelines = False + self.trueTime = 0 + self.slideshowThread = None + self.slideshowRunning = False + self.slideshowNext = 0 + self.osdTimer = None + self.lastItem = None + self.showPhotoThread = None + self.showPhotoTimeout = 0 + self.rotate = 0 + self.tempFolder = None + self.photoStack = [] + self.initialLoad = True + + def onFirstInit(self): + self.tempFolder = os.path.join(util.translatePath("special://temp/"), *self.tempSubFolder) + + if not os.path.exists(self.tempFolder): + try: + os.makedirs(self.tempFolder) + except OSError: + if not os.path.isdir(self.tempFolder): + util.ERROR() + + self.pqueueList = kodigui.ManagedControlList(self, self.PQUEUE_LIST_ID, 14) + #self.setProperty('photo', 'script.plex/indicators/busy-photo.gif') + try: + self.getPlayQueue() + except AttributeError: + raise util.NoDataException + self.start() + self.osdTimer = kodigui.PropertyTimer(self._winID, 4, 'OSD', '', init_value=False, callback=self.osdTimerCallback) + self.imageControl = self.getControl(600) + + def osdTimerCallback(self): + self.setFocusId(self.OVERLAY_BUTTON_ID) + + def onAction(self, action): + try: + # controlID = self.getFocusId() + if action == xbmcgui.ACTION_MOVE_LEFT: + if not self.osdVisible() or self.getFocusId() == self.PQUEUE_LIST_OVERLAY_BUTTON_ID: + self.prev() + elif action == xbmcgui.ACTION_MOVE_RIGHT: + if not self.osdVisible() or self.getFocusId() == self.PQUEUE_LIST_OVERLAY_BUTTON_ID: + self.next() + elif action == xbmcgui.ACTION_MOVE_UP: + if self.osdVisible(): + if self.getFocusId() == self.OVERLAY_BUTTON_ID: + self.hideOSD() + else: + self.showOSD() + elif action == xbmcgui.ACTION_MOVE_DOWN: + if self.osdVisible(): + if self.getFocusId() == self.OVERLAY_BUTTON_ID: + self.hideOSD() + else: + self.showOSD() + elif action == xbmcgui.ACTION_STOP: + self.stop() + elif action in (xbmcgui.ACTION_PLAYER_PLAY, xbmcgui.ACTION_PAUSE): + if self.isPlaying(): + self.pause() + else: + self.play() + elif action == xbmcgui.ACTION_PREV_ITEM: + self.prev() + elif action == xbmcgui.ACTION_NEXT_ITEM: + self.next() + elif action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK): + if self.osdVisible(): + self.hideOSD() + return + self.doClose() + return + + self.osdTimer.reset(init=False) + except: + util.ERROR() + + kodigui.BaseWindow.onAction(self, action) + + def checkPqueueListChanged(self): + item = self.pqueueList.getSelectedItem() + if item == self.lastItem: + return + + self.lastItem = item + self.onPqueueListChanged() + + def onClick(self, controlID): + if controlID == self.PREV_BUTTON_ID: + self.prev() + elif controlID == self.NEXT_BUTTON_ID: + next(self) + elif controlID == self.PLAY_PAUSE_BUTTON_ID: + if self.isPlaying(): + self.pause() + else: + self.play() + elif controlID == self.STOP_BUTTON_ID: + self.stop() + elif controlID == self.OVERLAY_BUTTON_ID: + self.showOSD() + elif controlID == self.SHUFFLE_BUTTON_ID: + self.shuffleButtonClicked() + elif controlID == self.REPEAT_BUTTON_ID: + self.repeatButtonClicked() + elif controlID == self.ROTATE_BUTTON_ID: + self.setRotation() + + def shuffleButtonClicked(self): + self.playQueue.setShuffle() + + def repeatButtonClicked(self): + if self.playQueue.isRepeat: + self.playQueue.setRepeat(False) + self.playQueue.refresh(force=True) + else: + self.playQueue.setRepeat(True) + self.playQueue.refresh(force=True) + + def setRotation(self, angle=None): + if angle is None: + self.resetSlideshowTimeout() + self.rotate += 90 + if self.rotate > 270: + self.rotate = 0 + else: + self.rotate = angle + + if self.rotate == 90: + self.imageControl.setPosition(420, -420) + self.imageControl.setWidth(1080) + self.imageControl.setHeight(1920) + elif self.rotate == 180: + self.imageControl.setPosition(0, 0) + self.imageControl.setWidth(1920) + self.imageControl.setHeight(1080) + elif self.rotate == 270: + self.imageControl.setPosition(420, -420) + self.imageControl.setWidth(1080) + self.imageControl.setHeight(1920) + else: + self.imageControl.setPosition(0, 0) + self.imageControl.setWidth(1920) + self.imageControl.setHeight(1080) + + self.setProperty('rotate', str(self.rotate)) + + def isPlaying(self): + return bool(self.getProperty('playing')) + + def getPlayQueue(self, shuffle=False): + if self.playQueue: + self.playQueue.on('items.changed', self.fillPqueueList) + self.playQueue.on('change', self.updateProperties) + self.updateProperties() + self.fillPqueueList() + else: + self.playQueue = playqueue.createPlayQueueForItem(self.photo, options={'shuffle': shuffle}) + self.playQueue.on('items.changed', self.fillPqueueList) + self.playQueue.on('change', self.updateProperties) + + util.DEBUG_LOG('waiting for playQueue to initialize') + if busy.widthDialog(self.playQueue.waitForInitialization, None): + util.DEBUG_LOG('playQueue initialized: {0}'.format(self.playQueue)) + else: + util.DEBUG_LOG('playQueue timed out wating for initialization') + + self.showPhoto() + + def fillPqueueList(self, **kwargs): + items = [] + for qi in self.playQueue.items(): + mli = kodigui.ManagedListItem(thumbnailImage=qi.thumb.asTranscodedImageURL(123, 123), data_source=qi) + items.append(mli) + + self.pqueueList.replaceItems(items) + self.updatePqueueListSelection() + + def updatePqueueListSelection(self, current=None): + selected = self.pqueueList.getListItemByDataSource(current or self.playQueue.current()) + if not selected or not selected.pos(): + return + + self.pqueueList.selectItem(selected.pos()) + + def showPhoto(self, trigger=None, **kwargs): + self.slideshowNext = 0 + + if not self.showPhotoThread or not self.showPhotoThread.is_alive(): + # if trigger is given, trigger it. trigger loads the next or prev item, depending on what was requested + # doing this here, this late prevents erratic behaviour when multiple next/prev calls were made but we were + # still loading images + if trigger: + trigger() + self.updateProperties() + + photo = self.playQueue.current() + + # fixme: video should work. + # bad temporary fix for videos in photo playqueues + if photo.type != "photo": + self.next() + util.DEBUG_LOG("SKIPPING PHOTO: %s" % photo) + return + + self.updatePqueueListSelection(photo) + + self.showPhotoTimeout = time.time() + 0.2 + if not self.showPhotoThread or not self.showPhotoThread.is_alive(): + self.showPhotoThread = threading.Thread(target=self._showPhoto, name="showphoto") + self.showPhotoThread.start() + + # wait for the current thread to end, which might still be loading the surrounding images, for 10 seconds + elif self.showPhotoThread.is_alive(): + waitedFor = 0 + self.setBoolProperty('is.updating', True) + while waitedFor < 100: + if not self.showPhotoThread.is_alive() and not util.MONITOR.abortRequested(): + return self.showPhoto(trigger=trigger, **kwargs) + elif util.MONITOR.abortRequested(): + self.setBoolProperty('is.updating', False) + return + + util.MONITOR.waitForAbort(0.1) + waitedFor += 0.1 + + # fixme raise error here + + def _showPhoto(self): + """ + load the current photo, preload the previous and the next one + :return: + """ + photo = self.playQueue.current() + next = self.playQueue.getNext() + loadItems = (photo, next, self.playQueue.getPrev()) + for item in loadItems: + if item is not None: + item.softReload() + + self.playerObject = plexplayer.PlexPhotoPlayer(photo) + + addToStack = [] + currentFailed = False + try: + for item in loadItems: + if not item: + continue + + if item.type != "photo": + continue + + meta = self.playerObject.build(item=item) + url = photo.server.getImageTranscodeURL(meta.get('url', ''), self.width, self.height) + bgURL = item.thumb.asTranscodedImageURL(self.width, self.height, blur=128, opacity=60, + background=colors.noAlpha.Background) + + isCurrent = currentFailed or item == photo + if isCurrent and not self.initialLoad: + self.setBoolProperty('is.updating', True) + + path, background = self.getCachedPhotoData(url, bgURL) + if not (path and background): + currentFailed = True + continue + + if (path, background) not in self.photoStack: + if item == next: + # move the next image to the top of the stack + addToStack.insert(0, (path, background)) + else: + addToStack.append((path, background)) + + if isCurrent: + self._reallyShowPhoto(item, path, background) + self.setBoolProperty('is.updating', False) + self.initialLoad = False + + # maintain cache folder + self.photoStack = addToStack + self.photoStack + if len(self.photoStack) > self.PHOTO_STACK_SIZE: + clean = self.photoStack[self.PHOTO_STACK_SIZE:] + self.photoStack = self.photoStack[:self.PHOTO_STACK_SIZE] + for remList in clean: + for rem in remList: + try: + os.remove(rem) + except: + pass + finally: + self.setBoolProperty('is.updating', False) + + def getCachedPhotoData(self, url, bgURL): + if not url: + return + + basename = hashlib.sha1(url.encode('utf-8')).hexdigest() + tmpPath = os.path.join(self.tempFolder, basename) + tmpBgPath = os.path.join(self.tempFolder, "%s_bg" % basename) + + for p, url in ((tmpPath, url), (tmpBgPath, bgURL)): + if not os.path.exists(p):# and not xbmc.getCacheThumbName(tmpFn): + try: + r = requests.get(url, allow_redirects=True, timeout=10.0) + r.raise_for_status() + except Exception as e: + util.ERROR("Couldn't load image: %s" % e, notify=True) + return None, None + else: + try: + with open(p, 'wb') as f: + f.write(r.content) + except: + return None, None + + return tmpPath, tmpBgPath + + def _reallyShowPhoto(self, photo, path, background): + self.setRotation(0) + self.setProperty('photo', path) + self.setProperty('background', background) + + self.setProperty('photo.title', photo.title) + self.setProperty('photo.date', util.cleanLeadingZeros(photo.originallyAvailableAt.asDatetime('%d %B %Y'))) + self.setProperty('camera.model', photo.media[0].model) + self.setProperty('camera.lens', photo.media[0].lens) + + if photo.media[0].height: + dims = u'{0} x {1}{2}'.format( + photo.media[0].width, + photo.media[0].height, + photo.media[0].parts[0].orientation and u' \u2022 {0} Mo'.format(photo.media[0].parts[0].orientation) or '' + ) + self.setProperty('photo.dims', dims) + settings = [] + if photo.media[0].iso: + settings.append('ISO {0}'.format(photo.media[0].iso)) + if photo.media[0].aperture: + settings.append('{0}'.format(photo.media[0].aperture)) + if photo.media[0].exposure: + settings.append('{0}'.format(photo.media[0].exposure)) + self.setProperty('camera.settings', u' \u2022 '.join(settings)) + self.setProperty('photo.summary', photo.summary) + container = photo.media[0].container_ or os.path.splitext(photo.media[0].parts[0].file)[-1][1:].lower() + if container == 'jpg': + container = 'jpeg' + self.setProperty('photo.container', container) + self.updateNowPlaying(force=True, refreshQueue=True) + self.resetSlideshowTimeout() + + def updateProperties(self, **kwargs): + self.setProperty('pq.shuffled', self.playQueue.isShuffled and '1' or '') + self.setProperty('pq.repeat', self.playQueue.isRepeat and '1' or '') + if not self.getProperty('hide.prev') and not self.playQueue.hasPrev(): + if self.playQueue.hasNext(): + self.setFocusId(self.NEXT_BUTTON_ID) + else: + self.setFocusId(self.PLAY_PAUSE_BUTTON_ID) + self.setProperty('hide.prev', not self.playQueue.hasPrev() and '1' or '') + if not self.getProperty('hide.next') and not self.playQueue.hasNext(): + if self.playQueue.hasPrev(): + self.setFocusId(self.PREV_BUTTON_ID) + else: + self.setFocusId(self.PLAY_PAUSE_BUTTON_ID) + self.setProperty('hide.next', not self.playQueue.hasNext() and '1' or '') + + def slideshow(self): + util.DEBUG_LOG('Slideshow: STARTED') + self.slideshowRunning = True + + # inhibit screensaver in matrix and above + if util.KODI_VERSION_MAJOR > 18: + xbmc.executebuiltin('InhibitScreensaver(true)') + + self.resetSlideshowTimeout() + while not util.MONITOR.waitForAbort(0.1) and self.slideshowRunning: + if not self.slideshowNext or time.time() < self.slideshowNext: + continue + self.next() + + if util.KODI_VERSION_MAJOR > 18: + xbmc.executebuiltin('InhibitScreensaver(false)') + + util.DEBUG_LOG('Slideshow: STOPPED') + + def resetSlideshowTimeout(self): + self.slideshowNext = time.time() + self.SLIDESHOW_INTERVAL + + def osdVisible(self): + return self.getProperty('OSD') + + def pqueueVisible(self): + return self.getProperty('show.pqueue') + + def start(self): + self.setFocusId(self.OVERLAY_BUTTON_ID) + + def prev(self): + if not self.playQueue.getPrev(): + return + self.showPhoto(trigger=lambda: self.playQueue.prev()) + + def next(self): + if not self.playQueue.getNext(): + return + self.showPhoto(trigger=lambda: self.playQueue.next()) + + __next__ = next + + def play(self): + self.setProperty('playing', '1') + if self.slideshowThread and self.slideshowThread.is_alive(): + return + + self.slideshowThread = threading.Thread(target=self.slideshow, name='slideshow') + self.slideshowThread.start() + + def pause(self): + self.setProperty('playing', '') + self.slideshowRunning = False + + def stop(self): + self.doClose() + + def doClose(self): + self.pause() + shutil.rmtree(self.tempFolder, ignore_errors=True) + + kodigui.BaseWindow.doClose(self) + + def getCurrentItem(self): + if self.playerObject: + return self.playerObject.item + return None + + def shouldSendTimeline(self, item): + return item.ratingKey and item.getServer() + + def updateNowPlaying(self, force=False, refreshQueue=False, state=None): + if self.ignoreTimelines: + return + + item = self.getCurrentItem() + + if not item: + return + + if not self.shouldSendTimeline(item): + return + + state = state or 'paused' + # Avoid duplicates + if state == self.lastTimelineState and not force: + return + + self.lastTimelineState = state + # self.timelineTimer.reset() + + time = int(self.trueTime * 1000) + + # self.trigger("progress", [m, item, time]) + + if refreshQueue and self.playQueue: + self.playQueue.refreshOnTimeline = True + + plexapp.util.APP.nowplayingmanager.updatePlaybackState(self.timelineType, self.playerObject, state, time, self.playQueue) + + def showOSD(self): + self.osdTimer.reset(init=False) + + def hideOSD(self, closing=False): + self.osdTimer.stop(trigger=True) diff --git a/script.plexmod/lib/windows/playbacksettings.py b/script.plexmod/lib/windows/playbacksettings.py new file mode 100644 index 000000000..5e614e1ae --- /dev/null +++ b/script.plexmod/lib/windows/playbacksettings.py @@ -0,0 +1,64 @@ +# coding=utf-8 +from lib import util +from lib.util import T + +from plexnet.util import INTERFACE + +from . import dropdown + + +class PlaybackSettingsMixin(object): + def playbackSettings(self, show, pos, bottom): + pbs = INTERFACE.playbackManager(show) + pbOpts = [] + transEnabled = T(33507) + transDisabled = T(33508) + + currentSettings = {} + for key, transID in INTERFACE.playbackManager.transMap.items(): + state = getattr(pbs, key) + stateTrans = state and transEnabled or transDisabled + pbOpts.append({'key': key, 'display': "{}: {}".format(stateTrans, T(transID))}) + currentSettings[key] = state + + # create a new dict which we can freely change while the dropdown is open + newSettings = currentSettings.copy() + + # fixme: not sure if garbage collection is necessary here + + def callback(opts, mli, force_off=False): + choice = mli.dataSource + oc = newSettings[choice["key"]] + ic = (not oc) if not force_off else False + + # invalidate any other setting if bingemode enabled + if choice["key"] == "binge_mode" and ic: + for m in opts.items: + if m.dataSource["key"] != "binge_mode": + callback(opts, m, force_off=True) + del m + + # disable bingeMode if any other setting is enabled + elif choice["key"] != "binge_mode" and newSettings["binge_mode"]: + m = opts.getListItem(0) + callback(opts, m, force_off=True) + del m + + newSettings[choice["key"]] = ic + label = choice["display"].replace("{}: ".format(oc and transEnabled or transDisabled), + "{}: ".format(ic and transEnabled or transDisabled)) + mli.setLabel(label) + mli.dataSource["display"] = label + del mli + + # fixme: not sure if garbage collection is necessary here + util.garbageCollect() + pbChoice = dropdown.showDropdown(pbOpts, pos, pos_is_bottom=bottom, close_direction='left', + set_dropdown_prop=True, + header="{}: {}".format(T(32925, 'Playback Settings'), show.defaultTitle), + close_only_with_back=True, align_items='left', + options_callback=callback) + + # write new settings for item if necessary + if newSettings != currentSettings: + INTERFACE.playbackManager(show, kv_dict=newSettings) diff --git a/script.plexmod/lib/windows/playerbackground.py b/script.plexmod/lib/windows/playerbackground.py new file mode 100644 index 000000000..e34073b15 --- /dev/null +++ b/script.plexmod/lib/windows/playerbackground.py @@ -0,0 +1,54 @@ +from __future__ import absolute_import +import contextlib +from . import kodigui +from lib import util + + +class PlayerBackground(kodigui.BaseWindow): + xmlFile = 'script-plex-player_background.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + def __init__(self, *args, **kwargs): + kodigui.BaseWindow.__init__(self, *args, **kwargs) + self.background = kwargs.get('background', '') + self._closedSet = False + + def onFirstInit(self): + self.setProperty('background', self.background) + + def onReInit(self): + if self._closedSet: + self._closedSet = False + self.doClose() + + @contextlib.contextmanager + def asContext(self): + self.show() + yield + self.doClose() + + def setClosed(self): + self._closedSet = True + self.doClose() + + +class PlayerBackgroundContext(object): + def __init__(self, **kwargs): + self.kwargs = kwargs + self.window = None + + def __enter__(self): + self.window = PlayerBackground.create(**self.kwargs) + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def close(self): + if self.window: + self.window.doClose() + del self.window + self.window = None diff --git a/script.plexmod/lib/windows/playersettings.py b/script.plexmod/lib/windows/playersettings.py new file mode 100644 index 000000000..0ad1c58ed --- /dev/null +++ b/script.plexmod/lib/windows/playersettings.py @@ -0,0 +1,340 @@ +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui + +from lib import util +from lib import metadata +from lib.util import T + +import plexnet + + +class VideoSettingsDialog(kodigui.BaseDialog, util.CronReceiver): + xmlFile = 'script-plex-video_settings_dialog.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + SETTINGS_LIST_ID = 100 + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + self.video = kwargs.get('video') + self.viaOSD = kwargs.get('via_osd') + self.nonPlayback = kwargs.get('non_playback') + self.parent = kwargs.get('parent') + self.roundRobin = kwargs.get('round_robin', True) + self.lastSelectedItem = 0 + + if not self.video.mediaChoice: + playerObject = plexnet.plexplayer.PlexPlayer(self.video) + playerObject.build() + + def onFirstInit(self): + self.settingsList = kodigui.ManagedControlList(self, self.SETTINGS_LIST_ID, 6) + self.setProperty('heading', T(32343, 'Settings')) + if self.viaOSD: + self.setProperty('via.OSD', '1') + self.showSettings(True) + util.CRON.registerReceiver(self) + + def onAction(self, action): + try: + if not xbmc.getCondVisibility('Player.HasMedia') and not self.nonPlayback: + self.doClose() + return + except: + util.ERROR() + + if self.roundRobin and action in (xbmcgui.ACTION_MOVE_UP, xbmcgui.ACTION_MOVE_DOWN) and \ + self.getFocusId() == self.SETTINGS_LIST_ID: + to_pos = None + last_index = self.settingsList.size() - 1 + if action == xbmcgui.ACTION_MOVE_UP and self.lastSelectedItem == 0 and self.settingsList.topHasFocus(): + to_pos = last_index + + elif action == xbmcgui.ACTION_MOVE_DOWN and self.lastSelectedItem == last_index \ + and self.settingsList.bottomHasFocus(): + to_pos = 0 + + if to_pos is not None: + self.settingsList.setSelectedItemByPos(to_pos) + self.lastSelectedItem = to_pos + return + + self.lastSelectedItem = self.settingsList.control.getSelectedPosition() + + kodigui.BaseDialog.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.SETTINGS_LIST_ID: + self.editSetting() + + def onClosed(self): + util.CRON.cancelReceiver(self) + + def tick(self): + if self.nonPlayback: + return + + if not xbmc.getCondVisibility('Player.HasMedia'): + self.doClose() + return + + def showSettings(self, init=False): + video = self.video + override = video.settings.getPrefOverride('local_quality') + if override is not None and override < 13: + current = T((32001, 32002, 32003, 32004, 32005, 32006, 32007, 32008, 32009, 32010, 32011, 32012, 32013, 32014)[13 - override]) + else: + current = u'{0} {1} ({2})'.format( + plexnet.util.bitrateToString(video.mediaChoice.media.bitrate.asInt() * 1000), + video.mediaChoice.media.getVideoResolutionString(), + video.mediaChoice.media.title or 'Original' + ) + + audio, subtitle = self.getAudioAndSubtitleInfo() + + options = [ + ('audio', T(32395, 'Audio'), audio), + ('subs', T(32396, 'Subtitles'), subtitle), + ('quality', T(32397, 'Quality'), u'{0}'.format(current)) + ] + + if not self.nonPlayback: + options += [ + ('kodi_video', T(32398, 'Kodi Video Settings'), ''), + ('kodi_audio', T(32399, 'Kodi Audio Settings'), ''), + ] + if util.KODI_VERSION_MAJOR >= 18: + options.append(('kodi_subtitle', T(32492, 'Kodi Subtitle Settings'), '')) + + if self.viaOSD: + if self.parent.getProperty("show.PPI"): + options += [ + ('stream_info', T(32483, 'Hide Stream Info'), ''), + ] + else: + options += [ + ('stream_info', T(32484, 'Show Stream Info'), ''), + ] + + items = [] + for o in options: + item = kodigui.ManagedListItem(o[1], o[2], data_source=o[0]) + items.append(item) + if init: + self.settingsList.reset() + self.settingsList.addItems(items) + else: + self.settingsList.replaceItems(items) + + self.setFocusId(self.SETTINGS_LIST_ID) + + def getAudioAndSubtitleInfo(self): + sas = self.video.selectedAudioStream() + audio = sas and sas.getTitle(metadata.apiTranslate) or T(32309, 'None') + + sss = self.video.selectedSubtitleStream( + forced_subtitles_override=util.getSetting("forced_subtitles_override", False)) + if sss: + if len(self.video.subtitleStreams) > 1: + subtitle = u'{0} \u2022 {1} {2}'.format(sss.getTitle(metadata.apiTranslate), len(self.video.subtitleStreams) - 1, T(32307, 'More')) + else: + subtitle = sss.getTitle(metadata.apiTranslate) + else: + if self.video.subtitleStreams: + subtitle = u'{0} \u2022 {1} {2}'.format(T(32309, 'None'), len(self.video.subtitleStreams), T(32308, 'Available')) + else: + subtitle = T(32309, 'None') + + return audio, subtitle + + def editSetting(self): + mli = self.settingsList.getSelectedItem() + if not mli: + return + + result = mli.dataSource + + if result == 'audio': + showAudioDialog(self.video, non_playback=self.nonPlayback) + elif result == 'subs': + showSubtitlesDialog(self.video, non_playback=self.nonPlayback) + elif result == 'quality': + idx = None + override = self.video.settings.getPrefOverride('local_quality') + if override is not None and override < 13: + idx = 13 - override + showQualityDialog(self.video, non_playback=self.nonPlayback, selected_idx=idx) + elif result == 'kodi_video': + xbmc.executebuiltin('ActivateWindow(OSDVideoSettings)') + elif result == 'kodi_audio': + xbmc.executebuiltin('ActivateWindow(OSDAudioSettings)') + elif result == 'kodi_subtitle': + xbmc.executebuiltin('ActivateWindow(OSDSubtitleSettings)') + elif result == "stream_info": + if self.parent: + if self.parent.getProperty("show.PPI"): + self.parent.hidePPIDialog() + else: + #xbmc.executebuiltin('Action(PlayerProcessInfo)') + self.parent.showPPIDialog() + self.doClose() + return + + self.showSettings() + + +class SelectDialog(kodigui.BaseDialog, util.CronReceiver): + xmlFile = 'script-plex-settings_select_dialog.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + OPTIONS_LIST_ID = 100 + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + self.heading = kwargs.get('heading') + self.options = kwargs.get('options') + self.selectedIdx = kwargs.get('selected_idx') + self.choice = None + self.nonPlayback = kwargs.get('non_playback') + self.lastSelectedItem = self.selectedIdx if self.selectedIdx is not None else 0 + self.roundRobin = kwargs.get('round_robin', True) + + def onFirstInit(self): + self.optionsList = kodigui.ManagedControlList(self, self.OPTIONS_LIST_ID, 8) + self.setProperty('heading', self.heading) + self.showOptions() + util.CRON.registerReceiver(self) + + def onAction(self, action): + try: + if not xbmc.getCondVisibility('Player.HasMedia') and not self.nonPlayback: + self.doClose() + return + except: + util.ERROR() + + if self.roundRobin and action in (xbmcgui.ACTION_MOVE_UP, xbmcgui.ACTION_MOVE_DOWN) and \ + self.getFocusId() == self.OPTIONS_LIST_ID: + to_pos = None + last_index = self.optionsList.size() - 1 + + if last_index > 0: + if action == xbmcgui.ACTION_MOVE_UP and self.lastSelectedItem == 0 and self.optionsList.topHasFocus(): + to_pos = last_index + + elif action == xbmcgui.ACTION_MOVE_DOWN and self.lastSelectedItem == last_index \ + and self.optionsList.bottomHasFocus(): + to_pos = 0 + + if to_pos is not None: + self.optionsList.setSelectedItemByPos(to_pos) + self.lastSelectedItem = to_pos + return + + self.lastSelectedItem = self.optionsList.control.getSelectedPosition() + + kodigui.BaseDialog.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.OPTIONS_LIST_ID: + self.setChoice() + + def onClosed(self): + util.CRON.cancelReceiver(self) + + def tick(self): + if self.nonPlayback: + return + + if not xbmc.getCondVisibility('Player.HasMedia'): + self.doClose() + return + + def setChoice(self): + mli = self.optionsList.getSelectedItem() + if not mli: + return + + self.choice = self.options[self.optionsList.getSelectedPosition()][0] + self.doClose() + + def showOptions(self): + items = [] + for o in self.options: + item = kodigui.ManagedListItem(o[1], data_source=o[0]) + items.append(item) + + self.optionsList.reset() + self.optionsList.addItems(items) + + if self.selectedIdx is not None: + self.optionsList.selectItem(self.selectedIdx) + + self.setFocusId(self.OPTIONS_LIST_ID) + + +def showOptionsDialog(heading, options, non_playback=False, selected_idx=None): + w = SelectDialog.open(heading=heading, options=options, non_playback=non_playback, selected_idx=selected_idx) + choice = w.choice + del w + util.garbageCollect() + return choice + + +def showAudioDialog(video, non_playback=False): + options = [] + idx = None + for i, s in enumerate(video.audioStreams): + if s.isSelected(): + idx = i + options.append((s, s.getTitle(metadata.apiTranslate))) + choice = showOptionsDialog(T(32395, 'Audio'), options, non_playback=non_playback, selected_idx=idx) + if choice is None: + return + + video.selectStream(choice) + + +def showSubtitlesDialog(video, non_playback=False): + options = [(plexnet.plexstream.NoneStream(), 'None')] + idx = None + for i, s in enumerate(video.subtitleStreams): + if s.isSelected(): + idx = i + 1 + options.append((s, s.getTitle(metadata.apiTranslate))) + + choice = showOptionsDialog(T(32396, 'Subtitle'), options, non_playback=non_playback, selected_idx=idx) + if choice is None: + return + + video.selectStream(choice) + video.manually_selected_sub_stream = choice.id + + +def showQualityDialog(video, non_playback=False, selected_idx=None): + options = [(13 - i, T(l)) for (i, l) in enumerate((32001, 32002, 32003, 32004, 32005, 32006, 32007, 32008, 32009, + 32010, 32011))] + + choice = showOptionsDialog('Quality', options, non_playback=non_playback, selected_idx=selected_idx) + if choice is None: + return + + video.settings.setPrefOverride('local_quality', choice) + video.settings.setPrefOverride('remote_quality', choice) + video.settings.setPrefOverride('online_quality', choice) + + +def showDialog(video, non_playback=False, via_osd=False, parent=None): + w = VideoSettingsDialog.open(video=video, non_playback=non_playback, via_osd=via_osd, parent=parent) + del w + util.garbageCollect() diff --git a/script.plexmod/lib/windows/playlist.py b/script.plexmod/lib/windows/playlist.py new file mode 100644 index 000000000..705fed372 --- /dev/null +++ b/script.plexmod/lib/windows/playlist.py @@ -0,0 +1,325 @@ +from __future__ import absolute_import +import threading + +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui + +from . import busy +from . import videoplayer +from . import windowutils +from . import dropdown +from . import search +import plexnet +from . import opener + +from lib import colors +from lib import util +from lib import player +from lib import backgroundthread + +from lib.util import T +from six.moves import range + +PLAYLIST_PAGE_SIZE = 500 +PLAYLIST_INITIAL_SIZE = 100 + + +class ChunkRequestTask(backgroundthread.Task): + WINDOW = None + + @classmethod + def reset(cls): + del cls.WINDOW + cls.WINDOW = None + + def setup(self, start, size): + self.start = start + self.size = size + return self + + def contains(self, pos): + return self.start <= pos <= (self.start + self.size) + + def run(self): + if self.isCanceled(): + return + + try: + items = self.WINDOW.playlist.extend(self.start, self.size) + if self.isCanceled(): + return + + if not self.WINDOW: # Window is closed + return + + self.WINDOW.chunkCallback(items, self.start) + except AttributeError: + util.DEBUG_LOG('Playlist window closed, ignoring chunk at index {0}'.format(self.start)) + except plexnet.exceptions.BadRequest: + util.DEBUG_LOG('404 on playlist: {0}'.format(repr(self.WINDOW.playlist.title))) + + +class PlaylistWindow(kodigui.ControlledWindow, windowutils.UtilMixin): + xmlFile = 'script-plex-playlist.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + OPTIONS_GROUP_ID = 200 + HOME_BUTTON_ID = 201 + SEARCH_BUTTON_ID = 202 + PLAYER_STATUS_BUTTON_ID = 204 + + PLAY_BUTTON_ID = 301 + SHUFFLE_BUTTON_ID = 302 + OPTIONS_BUTTON_ID = 303 + + LI_AR16X9_THUMB_DIM = util.scaleResolution(178, 100) + LI_SQUARE_THUMB_DIM = util.scaleResolution(100, 100) + + ALBUM_THUMB_DIM = util.scaleResolution(630, 630) + + PLAYLIST_LIST_ID = 101 + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + self.playlist = kwargs.get('playlist') + self.exitCommand = None + self.tasks = backgroundthread.Tasks() + self.isPlaying = False + ChunkRequestTask.WINDOW = self + + def onFirstInit(self): + self.playlistListControl = kodigui.ManagedControlList(self, self.PLAYLIST_LIST_ID, 5) + self.setProperties() + + self.fillPlaylist() + self.setFocusId(self.PLAYLIST_LIST_ID) + + # def onAction(self, action): + # try: + # if action in(xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): + # if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + # self.setFocusId(self.OPTIONS_GROUP_ID) + # return + # except: + # util.ERROR() + + # kodigui.ControlledWindow.onAction(self, action) + + def onAction(self, action): + try: + if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + self.doClose() + except: + util.ERROR() + + kodigui.ControlledWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.HOME_BUTTON_ID: + self.goHome() + elif controlID == self.PLAYLIST_LIST_ID: + self.playlistListClicked() + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + elif controlID == self.PLAY_BUTTON_ID: + self.playlistListClicked(no_item=True, shuffle=False) + elif controlID == self.SHUFFLE_BUTTON_ID: + self.playlistListClicked(no_item=True, shuffle=True) + elif controlID == self.OPTIONS_BUTTON_ID: + self.optionsButtonClicked() + elif controlID == self.SEARCH_BUTTON_ID: + self.searchButtonClicked() + + def doClose(self): + kodigui.ControlledWindow.doClose(self) + self.tasks.cancel() + ChunkRequestTask.reset() + + def searchButtonClicked(self): + self.processCommand(search.dialog(self)) + + def playlistListClicked(self, no_item=False, shuffle=False): + if no_item: + mli = None + else: + mli = self.playlistListControl.getSelectedItem() + if not mli or not mli.dataSource: + return + + try: + self.isPlaying = True + self.tasks.cancel() + player.PLAYER.stop() # Necessary because if audio is already playing, it will close the window when that is stopped + if self.playlist.playlistType == 'audio': + if self.playlist.leafCount.asInt() <= PLAYLIST_INITIAL_SIZE: + self.playlist.setShuffle(shuffle) + self.playlist.setCurrent(mli and mli.pos() or 0) + self.showAudioPlayer(track=mli and mli.dataSource or self.playlist.current(), playlist=self.playlist) + else: + args = {'sourceType': '8', 'shuffle': shuffle} + if mli: + args['key'] = mli.dataSource.key + pq = plexnet.playqueue.createPlayQueueForItem(self.playlist, options=args) + opener.open(pq) + elif self.playlist.playlistType == 'video': + if not util.advancedSettings.playlistVisitMedia: + if self.playlist.leafCount.asInt() <= PLAYLIST_INITIAL_SIZE: + self.playlist.setShuffle(shuffle) + self.playlist.setCurrent(mli and mli.pos() or 0) + videoplayer.play(play_queue=self.playlist) + else: + args = {'shuffle': shuffle} + if mli: + args['key'] = mli.dataSource.key + pq = plexnet.playqueue.createPlayQueueForItem(self.playlist, options=args) + opener.open(pq) + else: + if not mli: + firstItem = 0 + if shuffle: + import random + firstItem = random.randint(0, self.playlistListControl.size()-1) + mli = self.playlistListControl.getListItem(firstItem) + self.openItem(mli.dataSource) + + finally: + self.isPlaying = False + self.restartFill() + + def restartFill(self): + threading.Thread(target=self._restartFill).start() + + def _restartFill(self): + util.DEBUG_LOG('Checking if playlist list is full...') + for idx, mli in enumerate(self.playlistListControl): + if self.isPlaying or not self.isOpen or util.MONITOR.abortRequested(): + break + + if not mli.dataSource: + if self.playlist[idx]: + self.updateListItem(idx, self.playlist[idx]) + else: + break + # Update the progress for videos + elif mli.dataSource.type in ('episode', 'movie', 'clip'): + mli.dataSource.reload() + self.updateListItem(idx, mli.dataSource) + else: + util.DEBUG_LOG('Playlist list is full - nothing to do') + return + + util.DEBUG_LOG('Playlist list is not full - finishing') + total = self.playlist.leafCount.asInt() + for start in range(idx, total, PLAYLIST_PAGE_SIZE): + if util.MONITOR.abortRequested(): + break + self.tasks.add(ChunkRequestTask().setup(start, PLAYLIST_PAGE_SIZE)) + + backgroundthread.BGThreader.addTasksToFront(self.tasks) + + def optionsButtonClicked(self): + options = [] + if xbmc.getCondVisibility('Player.HasAudio + MusicPlayer.HasNext'): + options.append({'key': 'play_next', 'display': T(32325, 'Play Next')}) + + if not options: + return + + choice = dropdown.showDropdown(options, (440, 1020), close_direction='down', pos_is_bottom=True, close_on_playback_ended=True) + if not choice: + return + + if choice['key'] == 'play_next': + xbmc.executebuiltin('PlayerControl(Next)') + + def setProperties(self): + self.setProperty( + 'background', + util.backgroundFromArt(self.playlist.composite, width=self.width, height=self.height) + ) + self.setProperty('playlist.thumb', self.playlist.composite.asTranscodedImageURL(*self.ALBUM_THUMB_DIM)) + self.setProperty('playlist.title', self.playlist.title) + self.setProperty('playlist.duration', util.durationToText(self.playlist.duration.asInt())) + + def updateListItem(self, idx, pi, mli=None): + mli = mli or self.playlistListControl.getListItem(idx) + mli.setLabel(pi.title) + mli.setProperty('track.ID', pi.ratingKey) + mli.setProperty('track.number', str(idx + 1)) + mli.dataSource = pi + + if pi.type == 'track': + self.createTrackListItem(mli, pi) + elif pi.type == 'episode': + self.createEpisodeListItem(mli, pi) + elif pi.type in ('movie', 'clip'): + self.createMovieListItem(mli, pi) + + if pi.type in ('episode', 'movie', 'clip'): + mli.setProperty('progress', util.getProgressImage(mli.dataSource)) + + return mli + + def createTrackListItem(self, mli, track): + mli.setLabel2(u'{0} / {1}'.format(track.grandparentTitle, track.parentTitle)) + mli.setThumbnailImage(track.defaultThumb.asTranscodedImageURL(*self.LI_SQUARE_THUMB_DIM)) + mli.setProperty('track.duration', util.simplifiedTimeDisplay(track.duration.asInt())) + + def createEpisodeListItem(self, mli, episode): + label2 = u'{0} \u2022 {1}'.format( + episode.grandparentTitle, u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), episode.parentIndex, T(32311, 'E'), episode.index) + ) + mli.setLabel2(label2) + mli.setThumbnailImage(episode.thumb.asTranscodedImageURL(*self.LI_AR16X9_THUMB_DIM)) + mli.setProperty('track.duration', util.durationToShortText(episode.duration.asInt())) + mli.setProperty('video', '1') + mli.setProperty('watched', episode.isWatched and '1' or '') + + def createMovieListItem(self, mli, movie): + mli.setLabel(movie.defaultTitle) + mli.setLabel2(movie.year) + mli.setThumbnailImage(movie.art.asTranscodedImageURL(*self.LI_AR16X9_THUMB_DIM)) + mli.setProperty('track.duration', util.durationToShortText(movie.duration.asInt())) + mli.setProperty('video', '1') + mli.setProperty('watched', movie.isWatched and '1' or '') + + @busy.dialog() + def fillPlaylist(self): + total = self.playlist.leafCount.asInt() + + # leafCount is clamped to 6 when coming from Home/PlaylistsHub + actualPlaylistLength = len(self.playlist.items()) + + if total < len(self.playlist): + total = actualPlaylistLength + + endoffirst = min(PLAYLIST_INITIAL_SIZE, PLAYLIST_PAGE_SIZE, total) + items = [self.updateListItem(i, pi, kodigui.ManagedListItem()) for i, pi in enumerate(self.playlist.extend(0, endoffirst))] + + items += [kodigui.ManagedListItem() for i in range(total - endoffirst)] + + self.playlistListControl.reset() + self.playlistListControl.addItems(items) + + if total <= min(PLAYLIST_INITIAL_SIZE, PLAYLIST_PAGE_SIZE): + return + + for start in range(endoffirst, total, PLAYLIST_PAGE_SIZE): + if util.MONITOR.abortRequested(): + break + self.tasks.add(ChunkRequestTask().setup(start, PLAYLIST_PAGE_SIZE)) + + backgroundthread.BGThreader.addTasksToFront(self.tasks) + + def chunkCallback(self, items, start): + for i, pi in enumerate(items): + if self.isPlaying or not self.isOpen or util.MONITOR.abortRequested(): + break + + idx = start + i + self.updateListItem(idx, pi) diff --git a/script.plexmod/lib/windows/playlists.py b/script.plexmod/lib/windows/playlists.py new file mode 100644 index 000000000..12d22bb1f --- /dev/null +++ b/script.plexmod/lib/windows/playlists.py @@ -0,0 +1,138 @@ +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui + +from . import busy +from . import playlist +from . import windowutils +from . import search + +from lib import util +from lib import colors + +from plexnet import plexapp + + +class PlaylistsWindow(kodigui.ControlledWindow, windowutils.UtilMixin): + xmlFile = 'script-plex-playlists.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + THUMB_DIMS = { + 'audio': { + 'item.thumb': util.scaleResolution(270, 270) + }, + 'video': { + 'item.thumb': util.scaleResolution(610, 344) + } + } + + AUDIO_PL_LIST_ID = 101 + VIDEO_PL_LIST_ID = 301 + + OPTIONS_GROUP_ID = 200 + + HOME_BUTTON_ID = 201 + SEARCH_BUTTON_ID = 202 + PLAYER_STATUS_BUTTON_ID = 204 + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + self.exitCommand = None + + def onFirstInit(self): + self.audioPLListControl = kodigui.ManagedControlList(self, self.AUDIO_PL_LIST_ID, 5) + self.videoPLListControl = kodigui.ManagedControlList(self, self.VIDEO_PL_LIST_ID, 5) + + self.fill() + if self.audioPLListControl.size(): + self.setFocusId(self.AUDIO_PL_LIST_ID) + else: + self.setFocusId(self.VIDEO_PL_LIST_ID) + + def onAction(self, action): + try: + if action == xbmcgui.ACTION_CONTEXT_MENU: + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + self.setFocusId(self.OPTIONS_GROUP_ID) + return + # elif action in(xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): + # if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + # self.setFocusId(self.OPTIONS_GROUP_ID) + # return + + except: + util.ERROR() + + kodigui.ControlledWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.HOME_BUTTON_ID: + self.goHome() + elif controlID == self.AUDIO_PL_LIST_ID: + self.playlistListClicked(self.audioPLListControl) + elif controlID == self.VIDEO_PL_LIST_ID: + self.playlistListClicked(self.videoPLListControl) + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + elif controlID == self.SEARCH_BUTTON_ID: + self.searchButtonClicked() + + def searchButtonClicked(self): + self.processCommand(search.dialog(self)) + + def playlistListClicked(self, list_control): + mli = list_control.getSelectedItem() + if not mli: + return + + self.openWindow(playlist.PlaylistWindow, playlist=mli.dataSource) + + def createListItem(self, obj): + dimensions = self.THUMB_DIMS.get(obj.playlistType) + + if not dimensions: + return + + w, h = dimensions['item.thumb'] + + if obj.playlistType == 'audio': + thumb = obj.buildComposite(width=w, height=h, media='thumb') + else: + thumb = obj.buildComposite(width=w, height=h, media='art') + + mli = kodigui.ManagedListItem( + obj.title or '', + util.durationToText(obj.duration.asInt()), + # thumbnailImage=obj.composite.asTranscodedImageURL(*self.THUMB_DIMS[obj.playlistType]['item.thumb']), + thumbnailImage=thumb, + data_source=obj + ) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/{0}.png'.format(obj.playlistType == 'audio' and 'music' or 'movie')) + + return mli + + @busy.dialog() + def fill(self): + items = { + 'audio': [], + 'video': [] + } + playlists = plexapp.SERVERMANAGER.selectedServer.playlists() + + self.setProperty( + 'background', + util.backgroundFromArt(playlists[0].composite, width=self.width, height=self.height) + ) + + for pl in playlists: + mli = self.createListItem(pl) + if mli: + items[pl.playlistType].append(mli) + + self.audioPLListControl.addItems(items['audio']) + self.videoPLListControl.addItems(items['video']) diff --git a/script.plexmod/lib/windows/preplay.py b/script.plexmod/lib/windows/preplay.py new file mode 100644 index 000000000..5b8228b36 --- /dev/null +++ b/script.plexmod/lib/windows/preplay.py @@ -0,0 +1,720 @@ +from __future__ import absolute_import + +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui + +from . import busy +from . import opener +from . import info +from . import videoplayer +from . import playersettings +from . import search +from . import dropdown +from . import windowutils +from . import optionsdialog +from . import preplayutils +from . import pagination + +from plexnet import plexplayer, media + +from lib import util +from lib import metadata + +from lib.util import T + + +VIDEO_RELOAD_KW = dict(includeExtras=1, includeExtrasCount=10, includeChapters=1, includeReviews=1) + + +class RelatedPaginator(pagination.BaseRelatedPaginator): + def getData(self, offset, amount): + return self.parentWindow.video.getRelated(offset=offset, limit=amount) + + +class PrePlayWindow(kodigui.ControlledWindow, windowutils.UtilMixin): + xmlFile = 'script-plex-pre_play.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + THUMB_POSTER_DIM = util.scaleResolution(347, 518) + RELATED_DIM = util.scaleResolution(268, 397) + EXTRA_DIM = util.scaleResolution(329, 185) + ROLES_DIM = util.scaleResolution(334, 334) + PREVIEW_DIM = util.scaleResolution(343, 193) + + ROLES_LIST_ID = 400 + REVIEWS_LIST_ID = 401 + EXTRA_LIST_ID = 402 + RELATED_LIST_ID = 403 + + OPTIONS_GROUP_ID = 200 + PROGRESS_IMAGE_ID = 250 + + HOME_BUTTON_ID = 201 + SEARCH_BUTTON_ID = 202 + + INFO_BUTTON_ID = 304 + PLAY_BUTTON_ID = 302 + TRAILER_BUTTON_ID = 303 + SETTINGS_BUTTON_ID = 305 + OPTIONS_BUTTON_ID = 306 + MEDIA_BUTTON_ID = 307 + + PLAYER_STATUS_BUTTON_ID = 204 + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + self.video = kwargs.get('video') + self.parentList = kwargs.get('parent_list') + self.videos = None + self.exitCommand = None + self.trailer = None + self.lastFocusID = None + self.lastNonOptionsFocusID = None + self.initialized = False + self.relatedPaginator = None + + def doClose(self): + self.relatedPaginator = None + kodigui.ControlledWindow.doClose(self) + + def onFirstInit(self): + self.extraListControl = kodigui.ManagedControlList(self, self.EXTRA_LIST_ID, 5) + self.relatedListControl = kodigui.ManagedControlList(self, self.RELATED_LIST_ID, 5) + self.rolesListControl = kodigui.ManagedControlList(self, self.ROLES_LIST_ID, 5) + self.reviewsListControl = kodigui.ManagedControlList(self, self.REVIEWS_LIST_ID, 5) + + self.progressImageControl = self.getControl(self.PROGRESS_IMAGE_ID) + self.setup() + self.initialized = True + + def doAutoPlay(self): + # First reload the video to get all the other info + self.video.reload(checkFiles=1, **VIDEO_RELOAD_KW) + return self.playVideo() + + @busy.dialog() + def onReInit(self): + self.initialized = False + if util.getSetting("slow_connection", False): + self.progressImageControl.setWidth(1) + self.setProperty('remainingTime', T(32914, "Loading")) + self.video.reload(checkFiles=1, fromMediaChoice=self.video.mediaChoice is not None, **VIDEO_RELOAD_KW) + self.refreshInfo(from_reinit=True) + self.initialized = True + + def refreshInfo(self, from_reinit=False): + oldFocusId = self.getFocusId() + + util.setGlobalProperty('hide.resume', '' if self.video.viewOffset.asInt() else '1') + # skip setting background when coming from reinit (other window) if we've focused something other than main + self.setInfo(skip_bg=from_reinit and not (self.PLAY_BUTTON_ID <= oldFocusId <= self.MEDIA_BUTTON_ID)) + self.fillRelated() + xbmc.sleep(100) + + if oldFocusId == self.PLAY_BUTTON_ID: + self.focusPlayButton() + + def onAction(self, action): + try: + controlID = self.getFocusId() + + if not controlID and self.lastFocusID and not action == xbmcgui.ACTION_MOUSE_MOVE: + self.setFocusId(self.lastFocusID) + + if action == xbmcgui.ACTION_CONTEXT_MENU: + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + self.lastNonOptionsFocusID = self.lastFocusID + self.setFocusId(self.OPTIONS_GROUP_ID) + return + else: + if self.lastNonOptionsFocusID: + self.setFocusId(self.lastNonOptionsFocusID) + self.lastNonOptionsFocusID = None + return + + elif action == xbmcgui.ACTION_NAV_BACK: + if (not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format( + self.OPTIONS_GROUP_ID)) or not controlID) and \ + not util.advancedSettings.fastBack: + if self.getProperty('on.extras'): + self.setFocusId(self.OPTIONS_GROUP_ID) + return + + elif action == xbmcgui.ACTION_LAST_PAGE and xbmc.getCondVisibility('ControlGroup(300).HasFocus(0)'): + next(self) + elif action == xbmcgui.ACTION_NEXT_ITEM: + self.setFocusId(300) + next(self) + elif action == xbmcgui.ACTION_FIRST_PAGE and xbmc.getCondVisibility('ControlGroup(300).HasFocus(0)'): + self.prev() + elif action == xbmcgui.ACTION_PREV_ITEM: + self.setFocusId(300) + self.prev() + + elif action == xbmcgui.ACTION_MOVE_UP and controlID in (self.REVIEWS_LIST_ID, + self.ROLES_LIST_ID, + self.EXTRA_LIST_ID): + self.updateBackgroundFrom(self.video) + + if controlID == self.RELATED_LIST_ID: + if self.relatedPaginator.boundaryHit: + self.relatedPaginator.paginate() + return + elif action in (xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_MOVE_RIGHT): + self.updateBackgroundFrom(self.relatedListControl.getSelectedItem().dataSource) + except: + util.ERROR() + + kodigui.ControlledWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.HOME_BUTTON_ID: + self.goHome() + elif controlID == self.EXTRA_LIST_ID: + self.openItem(self.extraListControl) + elif controlID == self.RELATED_LIST_ID: + self.openItem(self.relatedListControl) + elif controlID == self.ROLES_LIST_ID: + self.roleClicked() + elif controlID == self.PLAY_BUTTON_ID: + self.playVideo() + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + elif controlID == self.INFO_BUTTON_ID: + self.infoButtonClicked() + elif controlID == self.SETTINGS_BUTTON_ID: + self.settingsButtonClicked() + elif controlID == self.TRAILER_BUTTON_ID: + self.openItem(item=self.trailer) + elif controlID == self.OPTIONS_BUTTON_ID: + self.optionsButtonClicked() + elif controlID == self.MEDIA_BUTTON_ID: + self.mediaButtonClicked() + elif controlID == self.SEARCH_BUTTON_ID: + self.searchButtonClicked() + + def onFocus(self, controlID): + self.lastFocusID = controlID + + if 399 < controlID < 500: + self.setProperty('hub.focus', str(controlID - 400)) + + if controlID == self.RELATED_LIST_ID: + self.updateBackgroundFrom(self.relatedListControl.getSelectedItem().dataSource) + + if xbmc.getCondVisibility('ControlGroup(50).HasFocus(0) + ControlGroup(300).HasFocus(0)'): + self.setProperty('on.extras', '') + elif xbmc.getCondVisibility('ControlGroup(50).HasFocus(0) + !ControlGroup(300).HasFocus(0)'): + self.setProperty('on.extras', '1') + + def searchButtonClicked(self): + self.processCommand(search.dialog(self, section_id=self.video.getLibrarySectionId() or None)) + + def settingsButtonClicked(self): + if not self.video.mediaChoice: + playerObject = plexplayer.PlexPlayer(self.video) + playerObject.build() + playersettings.showDialog(video=self.video, non_playback=True) + self.setAudioAndSubtitleInfo() + + def infoButtonClicked(self): + opener.handleOpen( + info.InfoWindow, + title=self.video.defaultTitle, + sub_title=self.getProperty('info'), + thumb=self.video.type == 'episode' and self.video.thumb or self.video.defaultThumb, + thumb_fallback='script.plex/thumb_fallbacks/{0}.png'.format(self.video.type == 'episode' and 'show' or 'movie'), + info=self.video.summary, + background=self.getProperty('background'), + is_16x9=self.video.type == 'episode', + video=self.video + ) + + def optionsButtonClicked(self): + options = [] + + inProgress = self.video.viewOffset.asInt() + if not self.video.isWatched or inProgress: + options.append({'key': 'mark_watched', 'display': T(32319, 'Mark Played')}) + if self.video.isWatched or inProgress: + options.append({'key': 'mark_unwatched', 'display': T(32318, 'Mark Unplayed')}) + + options.append(dropdown.SEPARATOR) + + if self.video.type == 'episode': + options.append({'key': 'to_season', 'display': T(32400, 'Go to Season')}) + options.append({'key': 'to_show', 'display': T(32323, 'Go to Show')}) + + if self.video.type in ('episode', 'movie'): + options.append({'key': 'to_section', 'display': T(32324, u'Go to {0}').format(self.video.getLibrarySectionTitle())}) + + if self.video.server.allowsMediaDeletion: + options.append({'key': 'delete', 'display': T(32322, 'Delete')}) + # if xbmc.getCondVisibility('Player.HasAudio') and self.section.TYPE == 'artist': + # options.append({'key': 'add_to_queue', 'display': 'Add To Queue'}) + + # if False: + # options.append({'key': 'add_to_playlist', 'display': 'Add To Playlist'}) + posy = 880 + if not util.getGlobalProperty('hide.resume'): + posy += 106 + if self.getProperty('trailer.button'): + posy += 106 + choice = dropdown.showDropdown(options, (posy, 618), close_direction='left') + if not choice: + return + + if choice['key'] == 'play_next': + xbmc.executebuiltin('PlayerControl(Next)') + elif choice['key'] == 'mark_watched': + self.video.markWatched(**VIDEO_RELOAD_KW) + self.refreshInfo() + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'mark_unwatched': + self.video.markUnwatched(**VIDEO_RELOAD_KW) + self.refreshInfo() + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'to_season': + self.processCommand(opener.open(self.video.parentRatingKey)) + elif choice['key'] == 'to_show': + self.processCommand(opener.open(self.video.grandparentRatingKey)) + elif choice['key'] == 'to_section': + self.goHome(self.video.getLibrarySectionId()) + elif choice['key'] == 'delete': + self.delete() + + def mediaButtonClicked(self): + options = [] + for media in self.video.media: + ind = '' + if self.video.mediaChoice and media.id == self.video.mediaChoice.media.id: + ind = 'script.plex/home/device/check.png' + options.append({'key': media, 'display': media.versionString(), 'indicator': ind}) + choice = dropdown.showDropdown(options, header=T(32450, 'Choose Version'), with_indicator=True) + if not choice: + return False + + for media in self.video.media: + media.set('selected', '') + + self.video.setMediaChoice(choice['key']) + choice['key'].set('selected', 1) + self.setInfo() + + def delete(self): + button = optionsdialog.show( + T(32326, 'Really delete?'), + T(32327, 'Are you sure you really want to delete this media?'), + T(32328, 'Yes'), + T(32329, 'No') + ) + + if button != 0: + return + + if self._delete(): + self.doClose() + else: + util.messageDialog(T(32330, 'Message'), T(32331, 'There was a problem while attempting to delete the media.')) + + @busy.dialog() + def _delete(self): + success = self.video.delete() + util.LOG('Media DELETE: {0} - {1}'.format(self.video, success and 'SUCCESS' or 'FAILED')) + return success + + def roleClicked(self): + mli = self.rolesListControl.getSelectedItem() + if not mli: + return + + sectionRoles = busy.widthDialog(mli.dataSource.sectionRoles, '') + + if not sectionRoles: + util.DEBUG_LOG('No sections found for actor') + return + + if len(sectionRoles) > 1: + x, y = self.getRoleItemDDPosition() + + options = [{'role': r, 'display': r.reasonTitle} for r in sectionRoles] + choice = dropdown.showDropdown(options, (x, y), pos_is_bottom=True, close_direction='bottom') + + if not choice: + return + + role = choice['role'] + else: + role = sectionRoles[0] + + if util.advancedSettings.dialogFlickerFix: + with busy.BusyContext(): + xbmc.sleep(100) + xbmc.sleep(650) + self.processCommand(opener.open(role)) + + def getVideos(self): + if not self.videos: + if self.video.TYPE == 'episode': + self.videos = self.video.show().episodes() + + if not self.videos: + return False + + return True + + def next(self): + if not self._next(): + return + self.setup() + + __next__ = next + + @busy.dialog() + def _next(self): + if self.parentList: + mli = self.parentList.getListItemByDataSource(self.video) + if not mli: + return False + + pos = mli.pos() + 1 + if not self.parentList.positionIsValid(pos): + pos = 0 + + self.video = self.parentList.getListItem(pos).dataSource + else: + if not self.getVideos(): + return False + + if self.video not in self.videos: + return False + + pos = self.videos.index(self.video) + pos += 1 + if pos >= len(self.videos): + pos = 0 + + self.video = self.videos[pos] + + return True + + def prev(self): + if not self._prev(): + return + self.setup() + + @busy.dialog() + def _prev(self): + if self.parentList: + mli = self.parentList.getListItemByDataSource(self.video) + if not mli: + return False + + pos = mli.pos() - 1 + if pos < 0: + pos = self.parentList.size() - 1 + + self.video = self.parentList.getListItem(pos).dataSource + else: + if not self.getVideos(): + return False + + if self.video not in self.videos: + return False + + pos = self.videos.index(self.video) + pos -= 1 + if pos < 0: + pos = len(self.videos) - 1 + + self.video = self.videos[pos] + + return True + + def getRoleItemDDPosition(self): + y = 200 + if xbmc.getCondVisibility('Control.IsVisible(500)'): + y += 360 + if xbmc.getCondVisibility('Control.IsVisible(501)'): + y += 520 + if xbmc.getCondVisibility('!String.IsEmpty(Window.Property(on.extras))'): + y -= 300 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),0) + Control.IsVisible(500)'): + y -= 500 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),1) + Control.IsVisible(501)'): + y -= 500 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),2) + Control.IsVisible(502)'): + y -= 500 + + focus = int(xbmc.getInfoLabel('Container(400).Position')) + + x = ((focus + 1) * 304) - 100 + return x, y + + def playVideo(self): + if not self.video.available(): + util.messageDialog(T(32312, 'Unavailable'), T(32313, 'This item is currently unavailable.')) + return + + resume = False + if self.video.viewOffset.asInt(): + choice = dropdown.showDropdown( + options=[ + {'key': 'resume', 'display': T(32429, 'Resume from {0}').format(util.timeDisplay(self.video.viewOffset.asInt()).lstrip('0').lstrip(':'))}, + {'key': 'play', 'display': T(32317, 'Play from beginning')} + ], + pos=(660, 441), + close_direction='none', + set_dropdown_prop=False, + header=T(32314, 'In Progress') + ) + + if not choice: + return + + if choice['key'] == 'resume': + resume = True + + self.processCommand(videoplayer.play(video=self.video, resume=resume)) + return True + + def openItem(self, control=None, item=None): + if not item: + mli = control.getSelectedItem() + if not mli: + return + item = mli.dataSource + + self.processCommand(opener.open(item)) + + def focusPlayButton(self): + try: + if not self.getFocusId() == self.PLAY_BUTTON_ID: + self.setFocusId(self.PLAY_BUTTON_ID) + except (SystemError, RuntimeError): + self.setFocusId(self.PLAY_BUTTON_ID) + + @busy.dialog() + def setup(self): + self.focusPlayButton() + + util.DEBUG_LOG('PrePlay: Showing video info: {0}'.format(self.video)) + if self.video.type == 'episode': + self.setProperty('preview.yes', '1') + elif self.video.type == 'movie': + self.setProperty('preview.no', '1') + + self.video.reload(checkFiles=1, **VIDEO_RELOAD_KW) + try: + self.relatedPaginator = RelatedPaginator(self.relatedListControl, leaf_count=int(self.video.relatedCount), + parent_window=self) + except ValueError: + raise util.NoDataException + + self.setInfo() + self.setBoolProperty("initialized", True) + hasRoles = self.fillRoles() + hasReviews = self.fillReviews() + hasExtras = self.fillExtras() + self.fillRelated(hasRoles and not hasExtras and not hasReviews) + + def setInfo(self, skip_bg=False): + if not skip_bg: + self.updateBackgroundFrom(self.video) + self.setProperty('title', self.video.title) + self.setProperty('duration', util.durationToText(self.video.duration.asInt())) + self.setProperty('summary', self.video.summary.strip().replace('\t', ' ')) + self.setProperty('unwatched', not self.video.isWatched and '1' or '') + + directors = u' / '.join([d.tag for d in self.video.directors()][:5]) + directorsLabel = len(self.video.directors) > 1 and T(32401, u'DIRECTORS').upper() or T(32383, u'DIRECTOR').upper() + self.setProperty('directors', directors and u'{0} {1}'.format(directorsLabel, directors) or '') + + if self.video.type == 'episode': + self.setProperty('content.rating', '') + self.setProperty('thumb', self.video.defaultThumb.asTranscodedImageURL(*self.THUMB_POSTER_DIM)) + self.setProperty('preview', self.video.thumb.asTranscodedImageURL(*self.PREVIEW_DIM)) + self.setProperty('info', u'{0} {1} {2} {3}'.format(T(32303, 'Season'), self.video.parentIndex, T(32304, 'Episode'), self.video.index)) + self.setProperty('date', util.cleanLeadingZeros(self.video.originallyAvailableAt.asDatetime('%B %d, %Y'))) + + writers = u' / '.join([w.tag for w in self.video.writers()][:5]) + writersLabel = len(self.video.writers) > 1 and T(32403, u'WRITERS').upper() or T(32402, u'WRITER').upper() + self.setProperty('writers', writers and u'{0} {1}'.format(writersLabel, writers) or '') + self.setProperty('related.header', T(32306, 'Related Shows')) + elif self.video.type == 'movie': + self.setProperty('title', self.video.defaultTitle) + self.setProperty('preview', '') + self.setProperty('thumb', self.video.thumb.asTranscodedImageURL(*self.THUMB_POSTER_DIM)) + genres = u' / '.join([g.tag for g in self.video.genres()][:3]) + self.setProperty('info', genres) + self.setProperty('date', self.video.year) + self.setProperty('content.rating', self.video.contentRating.split('/', 1)[-1]) + + cast = u' / '.join([r.tag for r in self.video.roles()][:5]) + castLabel = 'CAST' + self.setProperty('writers', cast and u'{0} {1}'.format(castLabel, cast) or '') + self.setProperty('related.header', T(32404, 'Related Movies')) + + self.setProperty('video.res', self.video.resolutionString()) + self.setProperty('audio.codec', self.video.audioCodecString()) + self.setProperty('video.codec', self.video.videoCodecString()) + self.setProperty('video.rendering', self.video.videoCodecRendering) + self.setProperty('audio.channels', self.video.audioChannelsString(metadata.apiTranslate)) + self.setBoolProperty('media.multiple', len(list(filter(lambda x: x.isAccessible(), self.video.media()))) > 1) + + self.setProperties(('rating.stars', 'rating', 'rating.image', 'rating2', 'rating2.image'), '') + if self.video.userRating: + stars = str(int(round((self.video.userRating.asFloat() / 10) * 5))) + self.setProperty('rating.stars', stars) + # elif self.video.rating: + # stars = str(int(round((self.video.rating.asFloat() / 10) * 5))) + # self.setProperty('rating.stars', stars) + + if self.video.ratingImage: + rating = self.video.rating + audienceRating = self.video.audienceRating + if self.video.ratingImage.startswith('rottentomatoes:'): + rating = '{0}%'.format(int(rating.asFloat() * 10)) + if audienceRating: + audienceRating = '{0}%'.format(int(audienceRating.asFloat() * 10)) + + self.setProperty('rating', rating) + self.setProperty('rating.image', 'script.plex/ratings/{0}.png'.format(self.video.ratingImage.replace('://', '/'))) + if self.video.audienceRatingImage: + self.setProperty('rating2', audienceRating) + self.setProperty('rating2.image', 'script.plex/ratings/{0}.png'.format(self.video.audienceRatingImage.replace('://', '/'))) + else: + self.setProperty('rating', self.video.rating) + + self.setAudioAndSubtitleInfo() + + self.setProperty('unavailable', all(not v.isAccessible() for v in self.video.media()) and '1' or '') + + if self.video.viewOffset.asInt(): + width = self.video.viewOffset.asInt() and (1 + int((self.video.viewOffset.asInt() / self.video.duration.asFloat()) * self.width)) or 1 + self.progressImageControl.setWidth(width) + else: + self.progressImageControl.setWidth(1) + + if self.video.viewOffset.asInt(): + self.setProperty('remainingTime', T(33615, "{time} left").format(time=self.video.remainingTimeString)) + else: + self.setProperty('remainingTime', '') + + def setAudioAndSubtitleInfo(self): + sas = self.video.selectedAudioStream() + self.setProperty('audio', sas and sas.getTitle(metadata.apiTranslate) or T(32309, 'None')) + + sss = self.video.selectedSubtitleStream( + forced_subtitles_override=util.getSetting("forced_subtitles_override", False)) + if sss: + if len(self.video.subtitleStreams) > 1: + self.setProperty( + 'subtitles', u'{0} \u2022 {1} {2}'.format(sss.getTitle(metadata.apiTranslate), len(self.video.subtitleStreams) - 1, T(32307, 'More')) + ) + else: + self.setProperty('subtitles', sss.getTitle(metadata.apiTranslate)) + else: + if self.video.subtitleStreams: + self.setProperty('subtitles', u'{0} \u2022 {1} {2}'.format(T(32309, 'None'), len(self.video.subtitleStreams), T(32308, 'Available'))) + else: + self.setProperty('subtitles', T(32309, u'None')) + + def createListItem(self, obj): + mli = kodigui.ManagedListItem(obj.title or '', thumbnailImage=obj.thumb.asTranscodedImageURL(*self.EXTRA_DIM), data_source=obj) + return mli + + def fillExtras(self, has_prev=False): + items = [] + idx = 0 + + if not self.video.extras: + self.extraListControl.reset() + return False + + for extra in self.video.extras(): + if not self.trailer and extra.extraType.asInt() == media.METADATA_RELATED_TRAILER: + self.trailer = extra + self.setProperty('trailer.button', '1') + continue + + mli = self.createListItem(extra) + if mli: + mli.setProperty('index', str(idx)) + mli.setProperty( + 'thumb.fallback', 'script.plex/thumb_fallbacks/{0}.png'.format(extra.type in ('show', 'season', 'episode') and 'show' or 'movie') + ) + items.append(mli) + idx += 1 + + if not items: + return False + + self.extraListControl.reset() + self.extraListControl.addItems(items) + + self.setProperty('divider.{0}'.format(self.EXTRA_LIST_ID), has_prev and '1' or '') + + return True + + def fillRelated(self, has_prev=False): + if not self.relatedPaginator.leafCount: + self.relatedListControl.reset() + return False + + items = self.relatedPaginator.paginate() + + if not items: + return False + + self.setProperty('divider.{0}'.format(self.RELATED_LIST_ID), has_prev and '1' or '') + + return True + + def fillRoles(self, has_prev=False): + items = [] + idx = 0 + + if not self.video.roles: + self.rolesListControl.reset() + return False + + for role in self.video.roles(): + mli = kodigui.ManagedListItem(role.tag, role.role, thumbnailImage=role.thumb.asTranscodedImageURL(*self.ROLES_DIM), data_source=role) + mli.setProperty('index', str(idx)) + items.append(mli) + idx += 1 + + if not items: + return False + + self.rolesListControl.reset() + self.rolesListControl.addItems(items) + return True + + def fillReviews(self, has_prev=False): + items = [] + idx = 0 + + if not self.video.reviews: + self.reviewsListControl.reset() + return False + + for review in self.video.reviews(): + mli = kodigui.ManagedListItem(review.source, review.tag, thumbnailImage=review.ratingImage()) + mli.setProperty('index', str(idx)) + mli.setProperty('text', review.text) + items.append(mli) + idx += 1 + + if not items: + return False + + self.reviewsListControl.reset() + self.reviewsListControl.addItems(items) + return True diff --git a/script.plexmod/lib/windows/preplayutils.py b/script.plexmod/lib/windows/preplayutils.py new file mode 100644 index 000000000..7d000622f --- /dev/null +++ b/script.plexmod/lib/windows/preplayutils.py @@ -0,0 +1,35 @@ +from __future__ import absolute_import +from . import dropdown + +from lib.util import T + + +def chooseVersion(video): + if len(video.media) > 1: + options = [] + for media in video.media: + ind = '' + if video.mediaChoice and media.id == video.mediaChoice.media.id: + ind = 'script.plex/home/device/check.png' + options.append({'key': media, 'display': media.versionString(), 'indicator': ind}) + choice = dropdown.showDropdown(options, header=T(32450, 'Choose Version'), with_indicator=True) + if not choice: + return False + + for media in video.media: + media.set('selected', '') + + video.setMediaChoice(choice['key']) + choice['key'].set('selected', 1) + + return True + + +def resetVersion(video): + if len(video.media) < 2: + return + + for media in video.media: + media.set('selected', '') + + video.media[0].set('selected', 1) diff --git a/script.plexmod/lib/windows/search.py b/script.plexmod/lib/windows/search.py new file mode 100644 index 000000000..2b4e2998c --- /dev/null +++ b/script.plexmod/lib/windows/search.py @@ -0,0 +1,435 @@ +from __future__ import absolute_import +import time +import threading + +from kodi_six import xbmcgui, xbmc + +from . import kodigui +from . import opener +from . import windowutils + +from lib import util +from lib.kodijsonrpc import rpc + +from plexnet import plexapp + +class SearchDialog(kodigui.BaseDialog, windowutils.UtilMixin): + xmlFile = 'script-plex-search.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + LETTERS = 'abcdefghijklmnopqrstuvwxyz0123456789 ' + SECTION_BUTTONS = { + 901: 'all', + 902: 'movie', + 903: 'show', + 904: 'artist', + 905: 'photo' + } + + EDIT_CONTROL_ID = 650 + BUTTON_A_ID = 1001 + + HUB_POSTER_00 = 2100 + HUB_SQUARE_01 = 2101 + HUB_AR16X9_02 = 2102 + HUB_CIRCLE_03 = 2103 + HUB_POSTER_04 = 2104 + HUB_SQUARE_05 = 2105 + HUB_AR16X9_06 = 2106 + HUB_CIRCLE_07 = 2107 + HUB_POSTER_08 = 2108 + HUB_SQUARE_09 = 2109 + HUB_AR16X9_10 = 2110 + HUB_CIRCLE_11 = 2111 + HUB_POSTER_12 = 2112 + HUB_SQUARE_13 = 2113 + HUB_AR16X9_14 = 2114 + HUB_CIRCLE_15 = 2115 + HUB_POSTER_16 = 2116 + HUB_SQUARE_17 = 2117 + HUB_AR16X9_18 = 2118 + HUB_CIRCLE_19 = 2119 + HUB_POSTER_20 = 2120 + HUB_SQUARE_21 = 2121 + HUB_AR16X9_22 = 2122 + HUB_CIRCLE_23 = 2123 + HUB_POSTER_24 = 2124 + HUB_SQUARE_25 = 2125 + HUB_AR16X9_26 = 2126 + HUB_CIRCLE_27 = 2127 + HUB_POSTER_28 = 2128 + HUB_SQUARE_29 = 2129 + HUB_AR16X9_30 = 2130 + HUB_CIRCLE_31 = 2131 + HUB_POSTER_32 = 2132 + HUB_SQUARE_33 = 2133 + HUB_AR16X9_34 = 2134 + HUB_CIRCLE_35 = 2135 + HUB_POSTER_36 = 2136 + HUB_SQUARE_37 = 2137 + HUB_AR16X9_38 = 2138 + HUB_CIRCLE_39 = 2139 + HUB_POSTER_40 = 2140 + HUB_SQUARE_41 = 2141 + HUB_AR16X9_42 = 2142 + HUB_CIRCLE_43 = 2143 + HUB_POSTER_44 = 2144 + HUB_SQUARE_45 = 2145 + HUB_AR16X9_46 = 2146 + HUB_CIRCLE_47 = 2147 + + HUBMAP = { + 'track': {'type': 'square'}, + 'episode': {'type': 'ar16x9'}, + 'movie': {'type': 'poster'}, + 'show': {'type': 'poster'}, + 'artist': {'type': 'square'}, + 'album': {'type': 'square'}, + 'photoalbum': {'type': 'square'}, + 'photo': {'type': 'square'}, + 'actor': {'type': 'circle'}, + 'director': {'type': 'circle'}, + 'genre': {'type': 'circle'}, + 'playlist': {'type': 'square'}, + } + + SECTION_TYPE_MAP = { + '1': {'thumb': 'script.plex/section_type/movie.png'}, # Movie + '2': {'thumb': 'script.plex/section_type/show.png'}, # Show + '3': {'thumb': 'script.plex/section_type/show.png'}, # Season + '4': {'thumb': 'script.plex/section_type/show.png'}, # Episode + '8': {'thumb': 'script.plex/section_type/music.png'}, # Artist + '9': {'thumb': 'script.plex/section_type/music.png'}, # Album + '10': {'thumb': 'script.plex/section_type/music.png'}, # Track + } + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + windowutils.UtilMixin.__init__(self) + self.parentWindow = kwargs.get('parent_window') + self.sectionID = kwargs.get('section_id') + self.resultsThread = None + self.updateResultsTimeout = 0 + self.isActive = True + self.useKodiKbd = util.getSetting('search_use_kodi_kbd', False) + + def onFirstInit(self): + self.hubControls = ( + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_00, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_01, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_02, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_03, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_04, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_05, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_06, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_07, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_08, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_09, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_10, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_11, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_12, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_13, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_14, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_15, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_16, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_17, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_18, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_19, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_20, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_21, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_22, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_23, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_24, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_25, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_26, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_27, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_28, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_29, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_30, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_31, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_32, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_33, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_34, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_35, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_36, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_37, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_38, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_39, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_40, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_41, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_42, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_43, 5) + }, + { + 'poster': kodigui.ManagedControlList(self, self.HUB_POSTER_44, 5), + 'square': kodigui.ManagedControlList(self, self.HUB_SQUARE_45, 5), + 'ar16x9': kodigui.ManagedControlList(self, self.HUB_AR16X9_46, 5), + 'circle': kodigui.ManagedControlList(self, self.HUB_CIRCLE_47, 5) + }, + ) + + self.edit = kodigui.SafeControlEdit(self.EDIT_CONTROL_ID, 651, self, key_callback=self.updateFromEdit, + grab_focus=True) + self.edit.setCompatibleMode(rpc.Application.GetProperties(properties=["version"])["version"]["major"] < 17) + if self.useKodiKbd: + self.setProperty('hide.kbd', '1') + self.setFocusId(self.EDIT_CONTROL_ID) + xbmc.executebuiltin('Action(Select,{0})'.format(self._winID)) + else: + self.setFocusId(self.BUTTON_A_ID) + self.setProperty('search.section', 'all') + self.updateQuery() + + def onAction(self, action): + try: + if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + self.isActive = False + except: + util.ERROR() + + kodigui.BaseDialog.onAction(self, action) + + def onClick(self, controlID): + if 1000 < controlID < 1037: + self.letterClicked(controlID) + elif controlID in self.SECTION_BUTTONS: + self.sectionClicked(controlID) + elif controlID == 951: + self.deleteClicked() + elif controlID == 952: + self.letterClicked(1037) + elif controlID == 953: + self.clearClicked() + elif 2099 < controlID < 2200: + self.hubItemClicked(controlID) + + def onFocus(self, controlID): + if 2099 < controlID < 2200: + self.setProperty('hub.focus', str(controlID - 2099)) + + def updateFromEdit(self, actionID, oldVal, newVal): + if actionID == xbmcgui.ACTION_PREVIOUS_MENU: + self.isActive = False + self.doClose() + return + + self.updateQuery() + + def updateQuery(self): + self.updateResults() + + def updateResults(self): + self.updateResultsTimeout = time.time() + 1 + if not self.resultsThread or not self.resultsThread.is_alive(): + self.resultsThread = threading.Thread(target=self._updateResults, name='search.update') + self.resultsThread.start() + + def _updateResults(self): + while time.time() < self.updateResultsTimeout and not util.MONITOR.waitForAbort(0.1): + pass + + self._reallyUpdateResults() + + def _reallyUpdateResults(self): + query = self.edit.getText() + if query: + with self.propertyContext('searching'): + hubs = plexapp.SERVERMANAGER.selectedServer.hubs(count=10, search_query=query, section=self.sectionID) + self.showHubs(hubs) + else: + self.clearHubs() + + def sectionClicked(self, controlID): + section = self.SECTION_BUTTONS[controlID] + old = self.getProperty('search.section') + self.setProperty('search.section', section) + if old != section: + self.updateResults() + + def letterClicked(self, controlID): + letter = self.LETTERS[controlID - 1001] + self.edit.append(letter) + self.updateQuery() + + def deleteClicked(self): + self.edit.delete() + self.updateQuery() + + def clearClicked(self): + self.edit.setText('') + self.updateQuery() + + def hubItemClicked(self, hubControlID): + for controls in self.hubControls: + for control in controls.values(): + if control.controlID == hubControlID: + break + else: + continue + break + else: + return + + mli = control.getSelectedItem() + if not mli: + return + + hubItem = mli.dataSource + if hubItem.TYPE == 'playlist' and not hubItem.exists(): # Workaround for server bug + util.messageDialog('No Access', 'Playlist not accessible by this user.') + util.DEBUG_LOG('Search: Playlist does not exist - probably wrong user') + return + + self.doClose() + try: + command = opener.open(hubItem) + + if not hubItem.exists(): + control.removeManagedItem(mli) + + self.processCommand(command) + finally: + if not self.exitCommand: + self.show() + else: + self.isActive = False + + def createListItem(self, hubItem): + if hubItem.TYPE in ('Genre', 'Director', 'Role'): + if hubItem.TYPE == 'Genre': + thumb = (self.SECTION_TYPE_MAP.get(hubItem.librarySectionType) or {}).get('thumb', '') + mli = kodigui.ManagedListItem(hubItem.tag, hubItem.reasonTitle, thumbnailImage=thumb, data_source=hubItem) + mli.setProperty('thumb.fallback', thumb) + else: + mli = kodigui.ManagedListItem( + hubItem.tag, hubItem.reasonTitle, thumbnailImage=hubItem.get('thumb').asTranscodedImageURL(256, 256), data_source=hubItem + ) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/role.png') + else: + if hubItem.TYPE == 'playlist': + mli = kodigui.ManagedListItem(hubItem.tag, thumbnailImage=hubItem.get('composite').asTranscodedImageURL(256, 256), data_source=hubItem) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/{0}.png'.format(hubItem.playlistType == 'audio' and 'music' or 'movie')) + elif hubItem.TYPE == 'photodirectory': + mli = kodigui.ManagedListItem(hubItem.title, thumbnailImage=hubItem.get('composite').asTranscodedImageURL(256, 256), data_source=hubItem) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/photo.png') + else: + mli = kodigui.ManagedListItem(hubItem.title, thumbnailImage=hubItem.get('thumb').asTranscodedImageURL(256, 256), data_source=hubItem) + if hubItem.TYPE in ('movie', 'clip'): + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/movie.png') + elif hubItem.TYPE in ('artist', 'album', 'track'): + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/music.png') + elif hubItem.TYPE in ('show', 'season', 'episode'): + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/show.png') + elif hubItem.TYPE == 'photo': + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/photo.png') + + return mli + + def showHubs(self, hubs): + self.clearHubs() + self.opaqueBackground(on=False) + + allowed = None + if self.getProperty('search.section') == 'movie': + allowed = ('movie',) + elif self.getProperty('search.section') == 'show': + allowed = ('show', 'season', 'episode') + elif self.getProperty('search.section') == 'artist': + allowed = ('artist', 'album', 'track') + elif self.getProperty('search.section') == 'photo': + allowed = ('photo', 'photodirectory') + + controlID = None + i = 0 + for h in hubs: + if allowed and h.type not in allowed: + continue + + if h.size.asInt() > 0: + self.opaqueBackground() + cid = self.showHub(h, i) + controlID = controlID or cid + i += 1 + + if controlID: + self.setProperty('no.results', '') + else: + self.setProperty('no.results', '1') + + def showHub(self, hub, idx): + util.DEBUG_LOG('Showing search hub: {0} at {1}'.format(hub.type, idx)) + info = self.HUBMAP.get(hub.type) + if not info: + util.DEBUG_LOG('Unhandled hub type: {0}'.format(hub.type)) + return + + itemListControl = self.hubControls[idx][info['type']] + if itemListControl is None: + util.DEBUG_LOG('No control defined') + return + + self.setProperty('hub.{0}'.format(itemListControl.controlID), hub.title) + + items = [] + for hubItem in hub.items: + mli = self.createListItem(hubItem) + items.append(mli) + + itemListControl.reset() + itemListControl.addItems(items) + + return itemListControl.controlID + + def clearHubs(self): + self.opaqueBackground(on=False) + self.setProperty('no.results', '') + for controls in self.hubControls: + for control in controls.values(): + if control: + control.reset() + self.setProperty('hub.focus', '') + + def opaqueBackground(self, on=True): + self.parentWindow.setProperty('search.dialog.hasresults', on and '1' or '') + + def wait(self): + while self.isActive and not util.MONITOR.waitForAbort(0.1): + pass + + +def dialog(parent_window, section_id=None): + parent_window.setProperty('search.dialog.hasresults', '') + with parent_window.propertyContext('search.dialog'): + try: + w = SearchDialog.open(parent_window=parent_window, section_id=section_id) + w.wait() + command = w.exitCommand or '' + del w + return command + finally: + parent_window.setProperty('search.dialog.hasresults', '') diff --git a/script.plexmod/lib/windows/seekdialog.py b/script.plexmod/lib/windows/seekdialog.py new file mode 100644 index 000000000..b3da4fe86 --- /dev/null +++ b/script.plexmod/lib/windows/seekdialog.py @@ -0,0 +1,2185 @@ +from __future__ import absolute_import +import re +import time +import threading +import math + +from kodi_six import xbmc +from kodi_six import xbmcgui +from collections import OrderedDict + +from . import kodigui +from . import playersettings +from . import dropdown +from . import busy +from plexnet import plexapp + +from lib import util +from plexnet.videosession import VideoSessionInfo, ATTRIBUTE_TYPES as SESSION_ATTRIBUTE_TYPES +from plexnet.exceptions import ServerNotOwned, NotFound +from plexnet.signalsmixin import SignalsMixin + +from lib.kodijsonrpc import builtin + +from lib.util import T +from six.moves import range + +KEY_MOVE_SET = frozenset( + ( + xbmcgui.ACTION_MOVE_LEFT, + xbmcgui.ACTION_MOVE_RIGHT, + xbmcgui.ACTION_MOVE_UP, + xbmcgui.ACTION_MOVE_DOWN + ) +) + +KEY_STEP_SEEK_SET = frozenset( + ( + xbmcgui.ACTION_MOVE_LEFT, + xbmcgui.ACTION_MOVE_RIGHT, + xbmcgui.ACTION_STEP_FORWARD, + xbmcgui.ACTION_STEP_BACK + ) +) + +MARKERS = OrderedDict([ + ("intro", { + "marker": None, + "name": T(32495, 'Skip intro'), + "autoSkipName": T(32800, 'Skipping intro'), + "overrideStartOff": None, + "countdown": None, + + # attrs + "markerAutoSkip": "autoSkipIntro", + "markerAutoSkipped": False, + "markerAutoSkipShownTimer": "_introSkipShownStarted", + "markerSkipBtnTimeout": "skipIntroButtonTimeout", + }), + ("credits", { + "marker": None, + "name": T(32496, 'Skip credits'), + "autoSkipName": T(32801, 'Skipping credits'), + "overrideStartOff": None, + "countdown": None, + + "markerAutoSkip": "autoSkipCredits", + "markerAutoSkipped": False, + "markerAutoSkipShownTimer": "_creditsSkipShownStarted", + "markerSkipBtnTimeout": "skipCreditsButtonTimeout" + }) +]) + +FINAL_MARKER_NEGOFF = 5000 +MARKER_SHOW_NEGOFF = 3000 +MARKER_OFF = 500 +MARKER_CHAPTER_OVERLAP_THRES = 30000 # 30 seconds + + +class SeekDialog(kodigui.BaseDialog): + """ + fixme: This is a convoluted mess. + """ + + xmlFile = 'script-plex-seek_dialog.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + MAIN_BUTTON_ID = 100 + SEEK_IMAGE_ID = 200 + POSITION_IMAGE_ID = 201 + SELECTION_INDICATOR = 202 + SELECTION_INDICATOR_GROUP = 203 + SELECTION_INDICATOR_IMAGE = 204 + SELECTION_INDICATOR_TEXT = 205 + CACHE_IMAGE_ID = 206 + BIF_IMAGE_ID = 300 + SEEK_IMAGE_WIDTH = 1920 + + REPEAT_BUTTON_ID = 401 + SHUFFLE_BUTTON_ID = 402 + SETTINGS_BUTTON_ID = 403 + PREV_BUTTON_ID = 404 + SKIP_BACK_BUTTON_ID = 405 + PLAY_PAUSE_BUTTON_ID = 406 + STOP_BUTTON_ID = 407 + SKIP_FORWARD_BUTTON_ID = 408 + NEXT_BUTTON_ID = 409 + PLAYLIST_BUTTON_ID = 410 + OPTIONS_BUTTON_ID = 411 + SUBTITLE_BUTTON_ID = 412 + + BIG_SEEK_GROUP_ID = 500 + BIG_SEEK_LIST_ID = 501 + + SKIP_MARKER_BUTTON_ID = 791 + NO_OSD_BUTTON_ID = 800 + + BAR_X = 0 + BAR_Y = 921 + BAR_RIGHT = 1920 + BAR_BOTTOM = 969 + + NAVBAR_BTN_SIZE = 60 + + HIDE_DELAY = 4 # This uses the Cron tick so is +/- 1 second accurate + OSD_HIDE_ANIMATION_DURATION = 0.2 + SKIP_STEPS = {"negative": [-10000], "positive": [30000]} + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + + # fixme: heyo, there's a lot of disorder in here. + self.handler = kwargs.get('handler') + self.initialVideoSettings = {} + self.initialAudioStream = None + self.initialSubtitleStream = None + self.bifURL = None + self.baseURL = None + self.hasBif = bool(self.bifURL) + self.baseOffset = 0 + self._duration = 0 + self.offset = 0 + self.selectedOffset = 0 + self.bigSeekOffset = 0 + self.bigSeekChanged = False # attention, with chapters this can become an integer for the True state + self.title = '' + self.title2 = '' + self.fromSeek = 0 + self.initialized = False + self.playlistDialog = None + self.timeout = None + self.autoSeekTimeout = None + self.hasDialog = False + self.lastFocusID = None + self.previousFocusID = None + self.playlistDialogVisible = False + self.forceNextTimeAsChapter = False + self.showChapters = False + self._seeking = False + self._applyingSeek = False + self._seekingWithoutOSD = False + self._delayedSeekThread = None + self._delayedSeekTimeout = 0 + self._osdHideAnimationTimeout = 0 + self._osdHideFast = False + self._hideDelay = self.HIDE_DELAY + self._autoSeekDelay = util.advancedSettings.autoSeekDelay + self._atSkipStep = -1 + self._lastSkipDirection = None + self._forcedLastSkipAmount = None + self._navigatedViaMarkerOrChapter = False + self._lastAction = None + self.lastTimelineResponse = None + self._ignoreInput = False + self._ignoreTick = False + self._abortBufferWait = False + + self._videoBelowOneHour = False + self.timeFmtKodi = util.timeFormatKN + self.waitingForBuffer = False + self.lastSubtitleNavAction = "forward" + self.subtitleButtonLeft = 0 + self.ldTimer = True #util.advancedSettings.lowDriftTimer + self.timeKeeper = None + self.timeKeeperTime = None + self.idleTime = None + self.stopPlaybackOnIdle = util.getSetting('player_stop_on_idle', 0) + self.isDirectPlay = True + self.isTranscoded = False + + # optimize + self._enableMarkerSkip = plexapp.ACCOUNT.hasPlexPass() + self.markers = None + self.chapters = None + self._introSkipShownStarted = None + self._creditsSkipShownStarted = None + self._currentMarker = None + self.skipSteps = self.SKIP_STEPS + self.useAutoSeek = util.advancedSettings.autoSeek + self.useDynamicStepsForTimeline = util.advancedSettings.dynamicTimelineSeek + + self.bingeMode = False + self.autoSkipIntro = False + self.autoSkipCredits = False + self.showIntroSkipEarly = False + + self.skipIntroButtonTimeout = util.advancedSettings.skipIntroButtonTimeout + self.skipCreditsButtonTimeout = util.advancedSettings.skipCreditsButtonTimeout + self.showItemEndsInfo = util.advancedSettings.showMediaEndsInfo + self.showItemEndsLabel = util.advancedSettings.showMediaEndsLabel + + self.player.video.server.on("np:timelineResponse", self.timelineResponseCallback) + + if util.kodiSkipSteps and util.advancedSettings.kodiSkipStepping: + self.skipSteps = {"negative": [], "positive": []} + for step in util.kodiSkipSteps: + key = "negative" if step < 0 else "positive" + self.skipSteps[key].append(step * 1000) + + self.skipSteps["negative"].reverse() + + try: + seconds = int(xbmc.getInfoLabel("Skin.String(SkinHelper.AutoCloseVideoOSD)")) + if seconds > 0: + self._hideDelay = seconds + except ValueError: + pass + + @property + def player(self): + return self.handler.player + + def timelineResponseCallback(self, **kwargs): + response = kwargs.get("response") + self.lastTimelineResponse = response.getBodyXml() + + def resetTimeout(self): + self.timeout = time.time() + self._hideDelay + + def resetAutoSeekTimer(self, value="not_set"): + self.autoSeekTimeout = value if value != "not_set" else time.time() + self._autoSeekDelay + + def resetSeeking(self): + self._seeking = False + self._seekingWithoutOSD = False + self._delayedSeekTimeout = None + self._applyingSeek = False + self.bigSeekChanged = False + self.selectedOffset = None + self.forceNextTimeAsChapter = False + self._navigatedViaMarkerOrChapter = False + self.setProperty('show.chapters', '') + self.setProperty('button.seek', '') + self.setProperty('marker.countdown', '') + self.resetAutoSeekTimer(None) + self.resetSkipSteps() + + def applyMarkerProps(self): + self.setProperty('show.markerSkip', '') + self.setProperty('show.markerSkip_OSDOnly', '') + self.setProperty('marker.autoSkip', '') + self.setProperty('skipMarkerName', '') + + if self.player.video.type == 'episode': + pbs = self.player.video.playbackSettings + util.DEBUG_LOG("Playback settings for {}: {}".format(self.player.video.ratingKey, pbs)) + + self.bingeMode = pbs.binge_mode + self.handler.inBingeMode = self.bingeMode + + # don't auto skip intro when on binge mode on the first episode of a season + firstEp = self.player.video.index == '1' + + if self.isDirectPlay or util.getUserSetting('auto_skip_in_transcode', True): + self.autoSkipIntro = (self.bingeMode and not firstEp) or pbs.auto_skip_intro + self.autoSkipCredits = self.bingeMode or pbs.auto_skip_credits + + self.showIntroSkipEarly = self.bingeMode or pbs.show_intro_skip_early + + self._introSkipShownStarted = None + self._introAutoSkipped = False + self._creditsSkipShownStarted = None + self._currentMarker = None + self._creditsAutoSkipped = False + self.markers = None + + def trueOffset(self): + if self.isDirectPlay: + return (self.handler.player.playerObject.startOffset * 1000) + self.offset + else: + return self.baseOffset + self.offset + + @property + def markers(self): + # fixme: fix transcoded marker skip + if not self._enableMarkerSkip: + return None + + if not self._markers and hasattr(self.handler.player.video, "markers"): + markers = [] + + for marker in self.handler.player.video.markers: + if marker.type in MARKERS: + m = MARKERS[marker.type].copy() + m["marker"] = marker + m["marker_type"] = marker.type + markers.append(m) + + self._markers = markers + + return self._markers + + @markers.setter + def markers(self, val): + self._markers = val + + def onFirstInit(self): + try: + self._onFirstInit() + except RuntimeError: + util.ERROR(hide_tb=True) + self.started = False + + def _onFirstInit(self): + util.DEBUG_LOG("SeekDialog: onFirstInit") + self.resetTimeout() + self.setProperty('skipMarkerName', T(32495, 'Skip intro')) + self.bigSeekHideTimer = kodigui.PropertyTimer(self._winID, 0.5, 'hide.bigseek') + + if self.handler.playlist: + self.handler.playlist.on('change', self.updateProperties) + + self.seekbarControl = self.getControl(self.SEEK_IMAGE_ID) + self.positionControl = self.getControl(self.POSITION_IMAGE_ID) + self.cacheControl = self.getControl(self.CACHE_IMAGE_ID) + self.bifImageControl = self.getControl(self.BIF_IMAGE_ID) + self.selectionIndicator = self.getControl(self.SELECTION_INDICATOR) + self.selectionIndicatorImage = self.getControl(self.SELECTION_INDICATOR_IMAGE) + self.selectionIndicatorText = self.getControl(self.SELECTION_INDICATOR_TEXT) + self.selectionIndicatorGroup = self.getControl(self.SELECTION_INDICATOR_GROUP) + self.selectionBox = self.getControl(203) + self.bigSeekControl = kodigui.ManagedControlList(self, self.BIG_SEEK_LIST_ID, 12) + self.bigSeekGroupControl = self.getControl(self.BIG_SEEK_GROUP_ID) + self.initialized = True + + showQuickSubs = util.getSetting('subtitle_downloads', False) + showRepeat = util.getSetting('video_show_repeat', False) + showFfwdRwd = util.getSetting('video_show_ffwdrwd', False) + showShuffle = util.getSetting('video_show_shuffle', False) + self.setBoolProperty('nav.quick_subtitles', showQuickSubs) + self.setBoolProperty('nav.repeat', showRepeat) + self.setBoolProperty('nav.ffwdrwd', showFfwdRwd) + self.setBoolProperty('nav.shuffle', showShuffle) + + if showQuickSubs: + self.subtitleButtonLeft += self.NAVBAR_BTN_SIZE * len( + list(x for x in (showRepeat, showFfwdRwd, showShuffle) if not x)) + + self.updateProperties() + self.updateChapters() + self.videoSettingsHaveChanged() + self.update() + + def onReInit(self): + util.DEBUG_LOG("SeekDialog: onReInit") + self.lastTimelineResponse = None + self._lastAction = None + self._ignoreTick = False + self.waitingForBuffer = False + self._abortBufferWait = False + + self.resetTimeout() + self.resetSeeking() + self.updateProperties() + self.updateChapters() + self.videoSettingsHaveChanged() + self.updateProgress() + + def setup(self, duration, meta, offset=0, bif_url=None, title='', title2='', chapters=None, keepMarkerDef=False): + """ + this is called by our handler and occurs earlier than onFirstInit. + """ + util.DEBUG_LOG("SeekDialog: setup, keepMarkerDef={}".format(keepMarkerDef)) + self.title = title + self.title2 = title2 + self.chapters = chapters or [] + self.isDirectPlay = not meta.isTranscoded + self.isTranscoded = not self.isDirectPlay + self.showChapters = util.getUserSetting('show_chapters', True) and ( + bool(chapters) or (util.getUserSetting('virtual_chapters', True) and bool(self.markers))) + self.setProperty('video.title', title) + self.setProperty('is.show', (self.player.video.type == 'episode') and '1' or '') + self.setProperty('has.playlist', self.handler.playlist and '1' or '') + self.setProperty('shuffled', (self.handler.playlist and self.handler.playlist.isShuffled) and '1' or '') + self.setProperty('has.chapters', self.showChapters and '1' or '') + self.setProperty('show.buffer', (util.advancedSettings.playerShowBuffer and self.isDirectPlay) and '1' or '') + + self.killTimeKeeper() + + navPlaylist = util.getSetting('video_show_playlist', 'eponly') + self.setBoolProperty('nav.playlist', (navPlaylist == "eponly" and self.player.video.type == 'episode') or + navPlaylist == "always") + + if not self.getProperty('nav.playlist'): + self.subtitleButtonLeft += self.NAVBAR_BTN_SIZE + + navPrevNext = util.getSetting('video_show_prevnext', 'eponly') + self.setBoolProperty('nav.prevnext', (navPrevNext == "eponly" and self.player.video.type == 'episode') or + navPrevNext == "always") + + if not self.getProperty('nav.prevnext'): + self.subtitleButtonLeft += self.NAVBAR_BTN_SIZE + + # in transcoded scenarios, when seeking, keep previous marker states, as the video restarts + if not keepMarkerDef: + try: + self.applyMarkerProps() + except IndexError: + self.doClose(delete=True) + raise util.NoDataException + self.baseOffset = offset + self.offset = 0 + self.idleTime = None + self.lastSubtitleNavAction = "forward" + self._duration = duration + self._videoBelowOneHour = duration / 3600000 < 1 + if self._videoBelowOneHour: + self.timeFmtKodi = self.timeFmtKodi.replace("hh:", "") + self._ignoreTick = False + self._ignoreInput = False + if not self.showChapters: + self.bifURL = bif_url + self.hasBif = bool(self.bifURL) + + if self.hasBif: + self.baseURL = re.sub('/\d+\?', '/{0}?', self.bifURL) + self.update() + + def update(self, offset=None, from_seek=False): + if from_seek: + self.fromSeek = time.time() + else: + if time.time() - self.fromSeek > 0.5: + self.fromSeek = 0 + + if offset is not None: + self.offset = offset + self.selectedOffset = self.trueOffset() + + self.updateProgress() + + def onAction(self, action): + if xbmc.getCondVisibility('Window.IsActive(selectdialog)'): + if self.doKodiSelectDialogHack(action): + return + + try: + self.resetTimeout() + + controlID = self.getFocusId() + self.idleTime = None + + lastAction = self._lastAction + self._lastAction = currentAction = (action.getId(), controlID) + + cancelActions = (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_STOP) + + if not self._ignoreInput: + if action.getId() in KEY_MOVE_SET: + self.setProperty('mouse.mode', '') + if not controlID: + self.setBigSeekShift() + self.setFocusId(400) + return + elif action == xbmcgui.ACTION_MOUSE_MOVE: + self.setProperty('mouse.mode', '1') + + if controlID in (self.MAIN_BUTTON_ID, self.NO_OSD_BUTTON_ID): + if action == xbmcgui.ACTION_MOUSE_LEFT_CLICK: + if self.getProperty('mouse.mode') != '1': + self.setProperty('mouse.mode', '1') + + self.seekMouse(action, without_osd=controlID == self.NO_OSD_BUTTON_ID) + return + elif action == xbmcgui.ACTION_MOUSE_MOVE: + self.seekMouse(action, without_osd=controlID == self.NO_OSD_BUTTON_ID, preview=True) + return + + if action in (xbmcgui.ACTION_PAUSE, xbmcgui.ACTION_PLAYER_PLAY, xbmcgui.ACTION_PLAYER_PLAYPAUSE) and \ + self.player.playState == self.player.STATE_PLAYING: + self.hideOSD() + + passThroughMain = False + if controlID == self.SKIP_MARKER_BUTTON_ID: + if action == xbmcgui.ACTION_SELECT_ITEM: + markerDef = self._currentMarker + if markerDef["marker"]: + marker = markerDef["marker"] + final = getattr(marker, "final", False) + + markerOff = 0 + if marker.type == "credits" and final: + # offset final marker seek so we can trigger postPlay + markerOff = FINAL_MARKER_NEGOFF + + util.DEBUG_LOG('MarkerSkip: Skipping marker {}'.format(markerDef["marker"])) + self.setProperty('show.markerSkip', '') + self.setProperty('show.markerSkip_OSDOnly', '') + self.doSeek(math.ceil(float(marker.endTimeOffset)) - markerOff) + self.hideOSD(skipMarkerFocus=True) + + if marker.type == "credits" and not final: + # non-final marker + setattr(self, markerDef["markerAutoSkipShownTimer"], None) + self.resetAutoSeekTimer(None) + + return + elif action == xbmcgui.ACTION_MOVE_DOWN: + self.setProperty('show.markerSkip_OSDOnly', '1') + self.showOSD() + elif action in (xbmcgui.ACTION_MOVE_RIGHT, xbmcgui.ACTION_STEP_FORWARD, xbmcgui.ACTION_MOVE_LEFT, + xbmcgui.ACTION_STEP_BACK): + # allow no-OSD-seeking with intro skip button shown + passThroughMain = True + elif action == xbmcgui.ACTION_MOVE_UP and self.osdVisible() and self.showChapters: + self.setProperty('show.chapters', '1') + self.setFocusId(self.BIG_SEEK_LIST_ID) + return + + if controlID == self.MAIN_BUTTON_ID: + # we're seeking from the timeline with the OSD open - do an actual timeline seek + if not self._seeking: + self.selectedOffset = self.trueOffset() + + if action in (xbmcgui.ACTION_MOVE_RIGHT, xbmcgui.ACTION_STEP_FORWARD): + if self.useDynamicStepsForTimeline: + return self.skipForward() + return self.seekByOffset(10000, auto_seek=self.useAutoSeek) + + elif action in (xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_STEP_BACK): + if self.useDynamicStepsForTimeline: + return self.skipBack() + return self.seekByOffset(-10000, auto_seek=self.useAutoSeek) + + elif action == xbmcgui.ACTION_MOVE_UP: + if self.getProperty('show.markerSkip') or self.getProperty('show.markerSkip_OSDOnly'): + # pressed up on player controls, then up on MAIN BUTTON; focus marker button + if currentAction == lastAction: + self.setFocusId(self.SKIP_MARKER_BUTTON_ID) + return + elif self.showChapters: + self.setProperty('show.chapters', '1') + + elif action == xbmcgui.ACTION_MOVE_DOWN: + if self.previousFocusID == self.BIG_SEEK_LIST_ID and ( + self.getProperty('show.markerSkip') or self.getProperty('show.markerSkip_OSDOnly')): + self.setFocusId(self.SKIP_MARKER_BUTTON_ID) + self.setProperty('show.chapters', '') + + self.updateBigSeek() + + # elif action == xbmcgui.ACTION_MOVE_UP: + # self.seekForward(60000) + # elif action == xbmcgui.ACTION_MOVE_DOWN: + # self.seekBack(60000) + + # don't auto-apply the currently selected seek when pressing down + elif controlID == self.PLAY_PAUSE_BUTTON_ID and self.previousFocusID == self.MAIN_BUTTON_ID \ + and action == xbmcgui.ACTION_MOVE_DOWN: + self.resetSeeking() + + elif controlID == self.NO_OSD_BUTTON_ID or passThroughMain: + if action in (xbmcgui.ACTION_MOVE_RIGHT, xbmcgui.ACTION_MOVE_LEFT): + # we're seeking from the timeline, with the OSD closed; act as we're skipping + if not self._seeking: + self.selectedOffset = self.trueOffset() + + if action == xbmcgui.ACTION_MOVE_RIGHT: + self.skipForward(without_osd=True) + + else: + self.skipBack(without_osd=True) + if action in (xbmcgui.ACTION_MOVE_UP, xbmcgui.ACTION_MOVE_DOWN): + # we're seeking from the timeline, with the OSD closed; act as we're skipping + if not self._seeking: + self.selectedOffset = self.trueOffset() + + if self.skipChapter(forward=(action == xbmcgui.ACTION_MOVE_UP), without_osd=True): + return + + if action in ( + xbmcgui.ACTION_MOVE_UP, + xbmcgui.ACTION_MOVE_DOWN, + xbmcgui.ACTION_BIG_STEP_FORWARD, + xbmcgui.ACTION_BIG_STEP_BACK + ) and not self._seekingWithoutOSD: + self.selectedOffset = self.trueOffset() + self.setBigSeekShift() + self.updateProgress() + self.showOSD() + + elif action.getButtonCode() == 61519: + if self.getProperty('show.PPI'): + self.hidePPIDialog() + else: + self.showPPIDialog() + return + + elif controlID == self.BIG_SEEK_LIST_ID: + if action in (xbmcgui.ACTION_MOVE_RIGHT, xbmcgui.ACTION_BIG_STEP_FORWARD): + return self.updateBigSeek(changed=True) + elif action in (xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_BIG_STEP_BACK): + return self.updateBigSeek(changed=True) + + elif action == xbmcgui.ACTION_MOVE_DOWN: + if self.getProperty('show.markerSkip'): + self.setProperty('show.chapters', '') + self.setFocusId(self.SKIP_MARKER_BUTTON_ID) + + if action.getButtonCode() == 61516: + self.cycleSubtitles() + elif action.getButtonCode() == 61524: + self.toggleSubtitles() + elif action.getButtonCode() == 323714: + # Alt-left + builtin.PlayerControl('tempodown') + elif action.getButtonCode() == 323715: + # Alt-right + builtin.PlayerControl('tempoup') + elif action == xbmcgui.ACTION_NEXT_ITEM: + self.handler.ignoreTimelines = True + self._ignoreTick = True + self.killTimeKeeper() + self.handler.next() + elif action == xbmcgui.ACTION_PREV_ITEM: + self.handler.ignoreTimelines = True + self._ignoreTick = True + self.killTimeKeeper() + self.handler.prev() + + if action in cancelActions + (xbmcgui.ACTION_SELECT_ITEM,): + if self.getProperty('show.PPI') and action in cancelActions: + self.hidePPIDialog() + self.hideOSD() + return + + # immediate marker timer actions + if self.countingDownMarker and \ + (self.getProperty('show.markerSkip') or self.getProperty('show.markerSkip_OSDOnly')): + + if controlID != self.BIG_SEEK_LIST_ID and \ + (util.advancedSettings.skipMarkerTimerCancel + or util.advancedSettings.skipMarkerTimerImmediate): + if util.advancedSettings.skipMarkerTimerCancel and \ + action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK): + self.displayMarkers(cancelTimer=True) + + elif util.advancedSettings.skipMarkerTimerImmediate \ + and action == xbmcgui.ACTION_SELECT_ITEM: + self.displayMarkers(immediate=True) + self.hideOSD(skipMarkerFocus=True) + return + + if action in cancelActions: + if self.waitingForBuffer: + self._abortBufferWait = True + self.waitingForBuffer = False + return + + if self._seeking and not self._ignoreInput: + self.resetSeeking() + self.updateCurrent() + self.updateProgress() + if self.osdVisible(): + self.hideOSD() + return + + if action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK): + if self._osdHideAnimationTimeout: + if self._osdHideAnimationTimeout >= time.time(): + return + else: + self._osdHideAnimationTimeout = None + + if self.osdVisible(): + self.hideOSD() + else: + self.stop() + return + except: + util.ERROR() + + kodigui.BaseDialog.onAction(self, action) + + def doKodiSelectDialogHack(self, action): + command = { + xbmcgui.ACTION_MOVE_UP: "Up", + xbmcgui.ACTION_MOVE_DOWN: "Down", + xbmcgui.ACTION_MOVE_LEFT: "Right", # Not sure if these are actually reversed or something else is up here + xbmcgui.ACTION_MOVE_RIGHT: "Left", + xbmcgui.ACTION_SELECT_ITEM: "Select", + xbmcgui.ACTION_PREVIOUS_MENU: "Back", + xbmcgui.ACTION_NAV_BACK: "Back" + }.get(action.getId()) + + if command is not None: + xbmc.executebuiltin('Action({0},selectdialog)'.format(command)) + return True + + return False + + def onFocus(self, controlID): + lastFocusID = self.lastFocusID + self.previousFocusID = self.lastFocusID + self.lastFocusID = controlID + if controlID == self.MAIN_BUTTON_ID: + # when seeking via ENTER/CLICK on chapters, coming directly from bigSeekSelected, don't assume we've + # already seeked. bigSeekSelected sets self.selectedOffset + if not self.showChapters: + self.selectedOffset = self.trueOffset() + + if lastFocusID == self.BIG_SEEK_LIST_ID and self.bigSeekChanged: + self.updateBigSeek(changed=True) + + # in case of chapter mode, bigSeekChanged holds our chapter's offset + offset = self.bigSeekChanged if self.showChapters else self.selectedOffset + self.updateProgress(set_to_current=False, offset=offset) + + # immediately seek bigSeek after click + self._performSeek(offset=offset) + self.hideOSD(skipMarkerFocus=True) + + else: + self.setBigSeekShift() + self.updateProgress() + + elif controlID == self.BIG_SEEK_LIST_ID: + self.setBigSeekShift() + self.updateBigSeek(changed=False) + + elif xbmc.getCondVisibility('ControlGroup(400).HasFocus(0)'): + self.selectedOffset = self.trueOffset() + self.updateProgress() + + def onClick(self, controlID): + if self._ignoreInput: + return + + if controlID in (self.MAIN_BUTTON_ID, self.NO_OSD_BUTTON_ID): + # only react to click events on our main areas if we're not in mouse mode, otherwise mouse seeking is + # handled by onAction + if self.getProperty('mouse.mode') != '1': + if controlID == self.MAIN_BUTTON_ID: + self.resetAutoSeekTimer(None) + self.doSeek() + elif controlID == self.NO_OSD_BUTTON_ID: + if not self._seeking: + # we might be reacting to an immediate marker skip while showing a marker with timeout; + # in that case, don't show the OSD + if not self._currentMarker or not util.advancedSettings.skipMarkerTimerImmediate or \ + self._currentMarker["countdown"] is None: + self.showOSD() + else: + # currently seeking without the OSD, apply the seek + self.doSeek() + elif controlID == self.PLAY_PAUSE_BUTTON_ID \ + and self.player.playState == self.player.STATE_PLAYING \ + and self.osdVisible(): + self.hideOSD() + elif controlID == self.STOP_BUTTON_ID: + self.stop() + elif controlID == self.SETTINGS_BUTTON_ID: + self.handleDialog(self.showSettings) + elif controlID == self.REPEAT_BUTTON_ID: + self.repeatButtonClicked() + elif controlID == self.SHUFFLE_BUTTON_ID: + self.shuffleButtonClicked() + elif controlID == self.PREV_BUTTON_ID: + self.handler.ignoreTimelines = True + self._ignoreTick = True + self.handler.prev() + elif controlID == self.NEXT_BUTTON_ID: + if not self.handler.queuingNext: + self.handler.ignoreTimelines = True + self.handler.queuingNext = True + self._ignoreTick = True + self._ignoreInput = True + self.killTimeKeeper() + next(self.handler) + return + elif controlID == self.PLAYLIST_BUTTON_ID: + self.showPlaylistDialog() + elif controlID == self.OPTIONS_BUTTON_ID: + self.handleDialog(self.optionsButtonClicked) + elif controlID == self.SUBTITLE_BUTTON_ID: + self.handleDialog(self.subtitleButtonClicked) + elif controlID == self.BIG_SEEK_LIST_ID: + self.bigSeekSelected() + elif controlID == self.SKIP_BACK_BUTTON_ID: + self.skipBack() + elif controlID == self.SKIP_FORWARD_BUTTON_ID: + self.skipForward() + + def stop(self): + self._ignoreTick = True + self.doClose() + # self.handler.onSeekAborted() + if self.bingeMode: + self.handler.stoppedInBingeMode = True + self.handler.player.stop() + + def doClose(self, delete=False): + if self.handler.playlist: + self.handler.playlist.off('change', self.updateProperties) + + try: + if self.playlistDialog: + self.playlistDialog.doClose() + if delete: + del self.playlistDialog + self.playlistDialog = None + util.garbageCollect() + + self.killTimeKeeper() + finally: + kodigui.BaseDialog.doClose(self) + + def showPPIDialog(self): + for attrib in SESSION_ATTRIBUTE_TYPES.values(): + self.setProperty('ppi.%s' % attrib.label, "") + + self.setProperty('show.PPI', '1') + self.setProperty('ppi.Status', 'Loading ...') + + def getVideoSession(currentVideo): + return currentVideo.server.findVideoSession(currentVideo.settings.getGlobal("clientIdentifier"), + currentVideo.ratingKey) + + if util.KODI_BUILD_NUMBER < 2090821: + try: + cache = int(xbmc.getInfoLabel('Player.ProgressCache')) - int(xbmc.getInfoLabel('Player.Progress')) + self.setProperty('ppi.Buffered', str(cache)) + except: + pass + + while not self.player.started: + util.MONITOR.waitForAbort(0.1) + + info = None + currentVideo = self.player.video + try: + videoSession = None + elapsed = 0 + while not videoSession: + if elapsed > 10: + raise NotFound + + videoSession = getVideoSession(currentVideo) + if videoSession: + break + + util.MONITOR.waitForAbort(1) + elapsed += 1 + + # fill attributes + info = VideoSessionInfo(videoSession, currentVideo) + + except ServerNotOwned: + # timeline response data fallback + elapsed = 0 + try: + while not self.lastTimelineResponse: + if elapsed > 10: + raise NotFound + + util.MONITOR.waitForAbort(0.1) + elapsed += 0.1 + + info = VideoSessionInfo(None, currentVideo, incompleteSessionData=self.lastTimelineResponse) + except NotFound: + self.setProperty('ppi.Status', 'Info not available (data not found)') + + except: + util.ERROR() + + except NotFound: + self.setProperty('ppi.Status', 'Info not available (session not found)') + + except: + util.ERROR() + + if info: + self.setProperty('ppi.Status', '') + for attrib in info.attributes.values(): + self.setProperty('ppi.%s' % attrib.label, attrib.value) + + def hidePPIDialog(self): + self.setProperty('show.PPI', '') + + def resetSkipSteps(self): + self._forcedLastSkipAmount = None + self._atSkipStep = -1 + self._lastSkipDirection = None + + def determineSkipStep(self, direction): + stepCount = len(self.skipSteps[direction]) + + # shortcut for simple skipping + if stepCount == 1: + return self.skipSteps[direction][0] + + use_direction = direction + + # kodi-style skip steps + + # when the direction changes, we either use the skip steps of the other direction, or walk backwards in the + # current skip step list + if self._lastSkipDirection != direction: + if self._atSkipStep == -1 or self._lastSkipDirection is None: + self._atSkipStep = 0 + self._lastSkipDirection = direction + self._forcedLastSkipAmount = None + step = self.skipSteps[use_direction][0] + + else: + # we're reversing the current direction + use_direction = self._lastSkipDirection + + # use the inverse value of the current skip step + step = self.skipSteps[use_direction][min(self._atSkipStep, len(self.skipSteps[use_direction]) - 1)] * -1 + + # we've hit a boundary, reverse the difference of the last skip step in relation to the boundary + if self._forcedLastSkipAmount is not None: + step = self._forcedLastSkipAmount * -1 + self._forcedLastSkipAmount = None + + # walk back one step + self._atSkipStep -= 1 + else: + # no reversal of any kind was requested and we've not hit any boundary, use the next skip step + if self._forcedLastSkipAmount is None: + self._atSkipStep += 1 + step = self.skipSteps[use_direction][min(self._atSkipStep, stepCount - 1)] + + else: + # we've hit a timeline boundary and haven't reversed yet. Don't do any further skipping + return + + return step + + def skipChapter(self, forward=True, without_osd=False): + lastSelectedOffset = self.selectedOffset + util.DEBUG_LOG('chapter skipping from {0} with forward {1}'.format(lastSelectedOffset, forward)) + if forward: + nextChapters = [c for c in self.chapters if c.startTime() > lastSelectedOffset] + util.DEBUG_LOG('Found {0} chapters among {1}'.format(len(nextChapters), len(self.chapters))) + if len(nextChapters) == 0: + return False + chapter = nextChapters[0] + else: + startTimeLimit = lastSelectedOffset - 2000 + if startTimeLimit < 0: + startTimeLimit = 0 + lastChapters = [c for c in self.chapters if c.startTime() <= startTimeLimit] + util.DEBUG_LOG('Found {0} chapters among {1}'.format(len(lastChapters), len(self.chapters))) + if len(lastChapters) == 0: + return False + chapter = lastChapters[-1] + + if chapter.tag: + util.DEBUG_LOG('Skipping to chapter: {}'.format(chapter.tag)) + self.forceNextTimeAsChapter = chapter.tag + + util.DEBUG_LOG('New start time is {0}'.format(chapter.startTime())) + self.skipByOffset(chapter.startTime() - lastSelectedOffset, without_osd=without_osd) + return True + + def skipForward(self, without_osd=False): + self.skipByStep("positive", without_osd) + + def skipBack(self, without_osd=False): + self.skipByStep("negative", without_osd) + + def skipByStep(self, direction="positive", without_osd=False): + step = self.determineSkipStep(direction) + self.skipByOffset(step, without_osd) + + def skipByOffset(self, offset, without_osd=False): + if self.countingDownMarker: + self.displayMarkers(cancelTimer=True) + + if offset is not None: + if not self.seekByOffset(offset, without_osd=without_osd): + return + + if self.useAutoSeek: + self.delayedSeek() + else: + self.setProperty('button.seek', '1') + + def delayedSeek(self): + self.setProperty('button.seek', '1') + delay = self._autoSeekDelay + + if delay > 0: + self._delayedSeekTimeout = time.time() + delay + + if not self._delayedSeekThread or not self._delayedSeekThread.is_alive(): + self._delayedSeekThread = threading.Thread(target=self._delayedSeek) + self._delayedSeekThread.start() + else: + # Do seek now + self._performSeek() + self.resetSeeking() + + def _delayedSeek(self): + try: + while not util.MONITOR.waitForAbort(0.1): + if not self._delayedSeekTimeout or time.time() > self._delayedSeekTimeout: + break + + if not util.MONITOR.abortRequested() and self._delayedSeekTimeout is not None: + self._performSeek() + except: + util.ERROR() + + def _performSeek(self, offset=None): + self._lastSkipDirection = None + self._forcedLastSkipAmount = None + self.doSeek(offset=offset) + + def handleDialog(self, func): + self.hasDialog = True + + try: + func() + finally: + self.resetTimeout() + self.hasDialog = False + + def videoSettingsHaveChanged(self): + changed = False + if ( + self.player.video.settings.prefOverrides != self.initialVideoSettings or + self.player.video.selectedAudioStream() != self.initialAudioStream + ): + self.initialVideoSettings = dict(self.player.video.settings.prefOverrides) + self.initialAudioStream = self.player.video.selectedAudioStream() + changed = True + + sss = self.player.video.selectedSubtitleStream() + if sss != self.initialSubtitleStream: + self.initialSubtitleStream = sss + if changed or self.isTranscoded: + return True + else: + return 'SUBTITLE' + + return changed + + def repeatButtonClicked(self): + pl = self.handler.playlist + + if pl: + if pl.isRepeatOne: + pl.setRepeat(False, one=False) + self.updateProperties() + elif pl.isRepeat: + pl.setRepeat(False, one=True) + pl.refresh(force=True) + else: + pl.setRepeat(True) + pl.refresh(force=True) + else: + xbmc.executebuiltin('PlayerControl(Repeat)') + + def shuffleButtonClicked(self): + if self.handler.playlist: + self.handler.playlist.setShuffle() + + def optionsButtonClicked(self): # Button currently commented out. + pass + # options = [] + + # options.append({'key': 'kodi_video', 'display': 'Video Options'}) + # options.append({'key': 'kodi_audio', 'display': 'Audio Options'}) + + # choice = dropdown.showDropdown(options, (1360, 1060), close_direction='down', pos_is_bottom=True, close_on_playback_ended=True) + + # if not choice: + # return + + # if choice['key'] == 'kodi_video': + # xbmc.executebuiltin('ActivateWindow(OSDVideoSettings)') + # elif choice['key'] == 'kodi_audio': + # xbmc.executebuiltin('ActivateWindow(OSDAudioSettings)') + + def subtitleButtonClicked(self): + options = [] + + options.append({'key': 'download', 'display': T(32405, 'Download Subtitles')}) + + # select "enable" by default + selectIndex = 1 + if self.lastSubtitleNavAction == "download": + selectIndex = 0 + + if self.player.video.hasSubtitles: + if self.player.video.hasSubtitle: + options.append({'key': 'delay', 'display': T(32406, 'Subtitle Delay')}) + + # select "disable" if we only have one subtitle + selectIndex = 2 + if self.lastSubtitleNavAction == "delay": + selectIndex = 1 + elif self.lastSubtitleNavAction == "download": + selectIndex = 0 + + if len(self.player.video.subtitleStreams) > 1: + options.append({'key': 'prev', 'display': T(32930, 'Previous Subtitle')}) + options.append({'key': 'next', 'display': T(32407, 'Next Subtitle')}) + + # select "next subtitle" if we already have subs active + selectIndex = 3 + + # select "prev subtitle" if we've last cycled backwards + if self.lastSubtitleNavAction == "backward": + selectIndex = 2 + elif self.lastSubtitleNavAction == "delay": + selectIndex = 1 + elif self.lastSubtitleNavAction == "download": + selectIndex = 0 + + options.append( + { + 'key': 'enable', + 'display': + xbmc.getCondVisibility('VideoPlayer.SubtitlesEnabled') and self.player.video.hasSubtitle and + T(32408, 'Disable Subtitles') or T(32409, 'Enable Subtitles') + } + ) + + # cheap and inaccurate approach to move the dropdown to the left based on how many buttons the user has hidden + choice = dropdown.showDropdown(options, (1360 - self.subtitleButtonLeft, 1060), close_direction='down', pos_is_bottom=True, + close_on_playback_ended=True, select_index=selectIndex) + + if not choice: + return + + if choice['key'] == 'download': + self.hideOSD() + self.lastSubtitleNavAction = "download" + builtin.ActivateWindow('SubtitleSearch') + elif choice['key'] == 'delay': + self.hideOSD() + self.lastSubtitleNavAction = "delay" + builtin.Action('SubtitleDelay') + elif choice['key'] == 'next': + self.cycleSubtitles() + self.lastSubtitleNavAction = "forward" + elif choice['key'] == 'prev': + self.cycleSubtitles(forward=False) + self.lastSubtitleNavAction = "backward" + elif choice['key'] == 'enable': + enabled = self.toggleSubtitles() + self.lastSubtitleNavAction = "forward" + + def toggleSubtitles(self): + """ + Used for subtitle toggling from button press or subtitle toggle menu + """ + if xbmc.getCondVisibility('VideoPlayer.SubtitlesEnabled') and self.player.video.hasSubtitle: + self.disableSubtitles() + return False + else: + self.cycleSubtitles() + return True + + def disableSubtitles(self): + self.player.video.disableSubtitles() + self.setSubtitles() + + def cycleSubtitles(self, forward=True): + """ + Selects the first subtitle or the next one + """ + stream = self.player.video.cycleSubtitles(forward=forward) + self.setSubtitles(honor_forced_subtitles_override=False) + util.showNotification(str(stream), time_ms=1500, header=util.T(32396, "Subtitles")) + + def setSubtitles(self, do_sleep=False, honor_forced_subtitles_override=False): + self.handler.setSubtitles(do_sleep=do_sleep, honor_forced_subtitles_override=honor_forced_subtitles_override) + if self.player.video.current_subtitle_is_embedded: + # this is an embedded stream, seek back a second after setting the subtitle due to long standing kodi + # issue: https://github.com/xbmc/xbmc/issues/21086 + util.DEBUG_LOG("Switching embedded subtitle stream, seeking due to Kodi issue #21086") + self.doSeek(self.trueOffset() - 100) + + def showSettings(self): + with self.propertyContext('settings.visible'): + playersettings.showDialog(self.player.video, via_osd=True, parent=self) + + changed = self.videoSettingsHaveChanged() + + if self.player.playState == self.player.STATE_PLAYING: + self._osdHideFast = True + + if changed == 'SUBTITLE': + self.setSubtitles(do_sleep=False) + self.lastSubtitleNavAction = "forward" + + elif changed: + self.doSeek(self.trueOffset(), settings_changed=True) + + def setBigSeekShift(self): + closest = None + if self.selectedOffset is None: + return + + for mli in self.bigSeekControl: + if mli.dataSource > self.selectedOffset: + break + closest = mli + if not closest: + return + + self.bigSeekOffset = self.selectedOffset - closest.dataSource + pxOffset = int(self.bigSeekOffset / float(self.duration) * 1920) + + if not self.showChapters: + self.bigSeekGroupControl.setPosition(-8 + pxOffset, 917) + self.bigSeekControl.selectItem(closest.pos()) + + self._seeking = True + # xbmc.sleep(100) + + def updateBigSeek(self, changed=False): + if changed and not self.showChapters: + self.bigSeekChanged = True + self.selectedOffset = self.bigSeekControl.getSelectedItem().dataSource + self.bigSeekOffset + self.updateProgress(set_to_current=False) + elif self.showChapters: + # when hovering chapters, show its corresponding time on the timeline, but don't act like we're seeking + self.updateProgress(set_to_current=False, offset=self.bigSeekControl.getSelectedItem().dataSource, + onlyTimeIndicator=True) + self.resetSkipSteps() + + def bigSeekSelected(self): + # this gets called when a click action happened on the bigSeek, defer the actual action to onFocus + # by setFocusId(MAIN) + + self.bigSeekChanged = True + if self.showChapters: + self.resetAutoSeekTimer(None) + self._navigatedViaMarkerOrChapter = True + + sel = self.bigSeekControl.getSelectedItem() + if self.bigSeekControl.isLastItem(sel): + self.selectedOffset = sel.dataSource - FINAL_MARKER_NEGOFF + else: + self.selectedOffset = sel.dataSource + MARKER_OFF + + # the onFocus action might take a couple of ms and might be overridden by onAction, store separately + self.bigSeekChanged = self.selectedOffset + + self.setFocusId(self.MAIN_BUTTON_ID) + + def updateProperties(self, **kwargs): + if not self.started: + return + + if self.fromSeek: + self.setFocusId(self.MAIN_BUTTON_ID) + self.fromSeek = 0 + + self.setProperty('has.bif', self.bifURL and '1' or '') + self.setProperty('video.title', self.title) + self.setProperty('video.title2', self.title2) + self.setProperty('is.show', (self.player.video.type == 'episode') and '1' or '') + self.setProperty('media.show_ends', self.showItemEndsInfo and '1' or '') + self.setProperty('time.ends_label', self.showItemEndsLabel and (util.T(32543, 'Ends at')) or '') + + if self.isDirectPlay: + self.setProperty('time.fmt', self.timeFmtKodi) + self.setProperty('time.fmt.ends', util.timeFormatKN.replace(":ss", "")) + + self.setBoolProperty('direct.play', self.isDirectPlay) + + if not self.getProperty('nav.playlist') and self.getProperty('nav.quick_subtitles'): + # offset the subtitle button + self.getControl(self.SUBTITLE_BUTTON_ID).setPosition(30, 0) + + if not self.getProperty('nav.prevnext'): + if self.getProperty('nav.ffwdrwd'): + self.getControl(self.SKIP_BACK_BUTTON_ID).setPosition(30, 0) + + pq = self.handler.playlist + if pq: + self.setProperty('has.playlist', '1') + self.setProperty('pq.isRemote', pq.isRemote and '1' or '') + self.setProperty('pq.hasnext', pq.hasNext() and '1' or '') + self.setProperty('pq.hasprev', pq.hasPrev() and '1' or '') + self.setProperty('pq.repeat', pq.isRepeat and '1' or '') + self.setProperty('pq.repeat.one', pq.isRepeatOne and '1' or '') + self.setProperty('pq.shuffled', pq.isShuffled and '1' or '') + else: + self.setProperties(('pq.isRemote', 'pq.hasnext', 'pq.hasprev', 'pq.repeat', 'pq.shuffled', 'has.playlist'), + '') + + self.updateCurrent() + + def updateChapters(self): + items = [] + + # replace bigSeek with chapters or markers if possible + if self.showChapters: + chaps = [] + chapOffsets = [] + if self.chapters: + self.setProperty('chapters.label', T(33605, 'Video Chapters').upper()) + for index, chapter in enumerate(self.chapters): + thumb = chapter.thumb and chapter.thumb.asTranscodedImageURL( + *PlaylistDialog.LI_AR16X9_THUMB_DIM) or None + # mli = kodigui.ManagedListItem(data_source=chapter.startTime(), + # thumbnailImage=thumb, + # label=chapter.tag or T(33607, 'Chapter {}').format(index + 1)) + # items.append(mli) + st = chapter.startTime() + chapOffsets.append(st) + chaps.append((st, thumb, chapter.tag or T(33607, 'Chapter {}').format(index + 1))) + + # fake chapters by using markers + if util.getSetting('virtual_chapters', True) and self.markers: + if not self.chapters: + self.setProperty('chapters.label', T(33606, 'Virtual Chapters').upper()) + else: + self.setProperty('chapters.label', T(33634, 'Combined Chapters').upper()) + creditsCounter = 0 + preparedMarkers = [] + for markerDef in self.markers: + marker = markerDef["marker"] + if marker: + if markerDef["marker_type"] == "intro": + preparedMarkers.append((int(marker.startTimeOffset), T(33608, "Intro"), False)) + preparedMarkers.append((int(marker.endTimeOffset), T(33610, "Main"), False)) + + elif markerDef["marker_type"] == "credits": + creditsCounter += 1 + if creditsCounter > 1 and getattr(marker, "final", False): + label = T(33635, "Final Credits") + else: + label = T(33609, "Credits") + "{}" + preparedMarkers.append((int(marker.startTimeOffset), label, True)) + + # add staggered virtual markers + preparedMarkers.append((int(self.duration * 0.25), "25 %", False)) + preparedMarkers.append((int(self.duration * 0.50), "50 %", False)) + preparedMarkers.append((int(self.duration * 0.75), "75 %", False)) + + credCnt = 1 + for offset, label, credits in sorted(preparedMarkers): + # filter intersections + skipMarker = False + for cOffset in chapOffsets: + if offset - MARKER_CHAPTER_OVERLAP_THRES <= cOffset <= offset + MARKER_CHAPTER_OVERLAP_THRES: + skipMarker = True + break + + # skip marker if we're overlapping with any chapter + if skipMarker: + continue + + chaps.append((offset, self.handler.player.playerObject.getBifUrl(offset), + label.format(" #{}".format(credCnt) if credits and creditsCounter > 1 else ""))) + + if credits: + credCnt += 1 + + for offset, thumb, label in sorted(chaps): + mli = kodigui.ManagedListItem(data_source=offset, thumbnailImage=thumb, label=label) + items.append(mli) + + else: + div = int(self.duration / 12) + for x in range(12): + offset = div * x + items.append(kodigui.ManagedListItem(data_source=offset)) + + # we might've been reinizialized by the handler and have had markers/chapters before. reset height and + # positioning of the bigSeekControl + self.bigSeekControl.control.setHeight(16) + self.bigSeekControl.control.setPosition(self.bigSeekControl.getX(), 0) + + self.bigSeekControl.reset() + self.bigSeekControl.addItems(items) + + if self.showChapters: + # adjust height and positioning of bigSeekControl to accomodate chapters + self.bigSeekControl.control.setHeight(160) + self.bigSeekControl.control.setPosition(self.bigSeekControl.getX(), -126) + + def updateCurrent(self, update_position_control=True, atOffset=None): + ratio = self.trueOffset() / float(self.duration) + + if update_position_control: + w = int(ratio * self.SEEK_IMAGE_WIDTH) + self.positionControl.setWidth(w) + + # update cache/buffer bar + if util.advancedSettings.playerShowBuffer and self.isDirectPlay and util.KODI_VERSION_MAJOR > 18: + cache_w = int(xbmc.getInfoLabel("Player.ProgressCache")) * self.SEEK_IMAGE_WIDTH // 100 + self.cacheControl.setWidth(cache_w) + + if self.isTranscoded: + to = atOffset if atOffset is not None else self.trueOffset() + self.setProperty('time.current', util.timeDisplay(to, cutHour=self._videoBelowOneHour)) + self.setProperty('time.left', + util.timeDisplay(self.duration - to, cutHour=self._videoBelowOneHour)) + + _fmt = util.timeFormat.replace(":%S", "") + + val = time.strftime(_fmt, time.localtime(time.time() + ((self.duration - to) / 1000))) + if not util.padHour and val[0] == "0" and val[1] != ":": + val = val[1:] + + self.setProperty('time.end', val) + + def doSeek(self, offset=None, settings_changed=False): + self._applyingSeek = True + self._ignoreInput = settings_changed + offset = self.selectedOffset if offset is None else offset + + if self.countingDownMarker: + self.displayMarkers(cancelTimer=True) + + self.resetSkipSteps() + self.updateProgress(offset=offset) + + try: + self.handler.seek(offset, settings_changed=settings_changed) + finally: + self.resetSeeking() + + def seekByOffset(self, offset, auto_seek=False, without_osd=False): + """ + Sets the selected offset and updates the progress bar to visually represent the current seek + :param offset: offset to seek to + :param auto_seek: whether to automatically seek to :offset: after a certain amount of time + :param without_osd: indicates whether this seek was done with or without OSD + :return: + """ + if self.selectedOffset is None: + self.selectedOffset = self.offset + lastSelectedOffset = self.selectedOffset + # If we are seeking forward and already past 5 seconds from end, don't seek at all + if lastSelectedOffset > self.duration - 5000 and offset > 0: + return False + + self._seeking = True + self._seekingWithoutOSD = without_osd + self.selectedOffset += offset + # Don't skip past 5 seconds from end + if self.selectedOffset > self.duration - 5000: + # offset = +100, at = 80000, duration = 80007, realoffset = 2 + self._forcedLastSkipAmount = self.duration - 5000 - lastSelectedOffset + self.selectedOffset = self.duration - 5000 + # Don't skip back past 1 (0 is handled specially so seeking to 0 will not do a seek) + elif self.selectedOffset < 1: + # offset = -100, at = 5, realat = -95, realoffset = 1 - 5 = -4 + self._forcedLastSkipAmount = 1 - lastSelectedOffset + self.selectedOffset = 1 + + self.updateProgress(set_to_current=False) + self.setBigSeekShift() + if auto_seek: + self.resetAutoSeekTimer() + self.bigSeekHideTimer.reset() + return True + + def seekMouse(self, action, without_osd=False, preview=False): + x = self.mouseXTrans(action.getAmount1()) + y = self.mouseYTrans(action.getAmount2()) + if not (self.BAR_Y <= y <= self.BAR_BOTTOM): + return + + if not (self.BAR_X <= x <= self.BAR_RIGHT): + return + + self._seeking = True + self._seekingWithoutOSD = without_osd + + self.selectedOffset = int((x - self.BAR_X) / float(self.SEEK_IMAGE_WIDTH) * self.duration) + if not preview: + self.doSeek() + if not xbmc.getCondVisibility('Window.IsActive(videoosd) | Player.Rewinding | Player.Forwarding'): + self.hideOSD() + else: + self.updateProgress(set_to_current=False) + self.setProperty('button.seek', '1') + + def getCurrentMarkerDef(self, offset=None): + """ + Show intro/credits skip button at current time + """ + + if not self.markers: + return + + off = offset if offset is not None else self.trueOffset() + + for markerDef in self.markers: + marker = markerDef["marker"] + if marker: + startTimeOffset = int(marker.startTimeOffset) + + # show intro skip early? (only if intro is during the first X minutes) + if self.showIntroSkipEarly and markerDef["marker_type"] == "intro" and \ + startTimeOffset <= util.advancedSettings.skipIntroButtonShowEarlyThreshold1 * 1000: + startTimeOffset = 0 + markerDef["overrideStartOff"] = 0 + + markerEndNegoff = FINAL_MARKER_NEGOFF if getattr(markerDef["marker"], "final", False) else 0 + + if startTimeOffset - MARKER_SHOW_NEGOFF <= off < int(marker.endTimeOffset) - markerEndNegoff: + + return markerDef + + + @property + def duration(self): + try: + return self._duration or int(self.handler.player.getTotalTime() * 1000) + except RuntimeError: # Not playing + return 1 + + def updateProgress(self, set_to_current=True, offset=None, onlyTimeIndicator=False): + """ + Updates the progress bars (seek and position) and the currently-selected-time-label for the current position or + seek state on the timeline. + :param set_to_current: if True, sets both the position bar and the seek bar to the currently selected position, + otherwise we're in seek mode, whereas one of both bars move relatively to the currently + selected position depending on the direction of the seek + :return: None + """ + if not self.initialized: + return + + offset = offset if offset is not None else \ + self.selectedOffset if self.selectedOffset is not None else self.trueOffset() + ratio = offset / float(self.duration) + w = int(ratio * self.SEEK_IMAGE_WIDTH) + + current_w = int(self.offset / float(self.duration) * self.SEEK_IMAGE_WIDTH) + + bifx = (w - int(ratio * 324)) + self.BAR_X + # bifx = w + self.selectionIndicator.setPosition(w, 896) + if w < 51: + self.selectionBox.setPosition(-50 + (50 - w), 0) + elif w > 1869: + self.selectionBox.setPosition(-100 + (1920 - w), 0) + else: + self.selectionBox.setPosition(-50, 0) + + if self.forceNextTimeAsChapter: + self.setProperty('time.selection', self.forceNextTimeAsChapter) + + # fixme: might be superfluous + self.selectionIndicatorImage.setWidth(self.selectionIndicatorText.getWidth()) + self.forceNextTimeAsChapter = False + else: + self.setProperty('time.selection', util.simplifiedTimeDisplay(offset)) + self.selectionIndicatorImage.setWidth(101) + + if onlyTimeIndicator: + return + + if self.hasBif: + self.setProperty('bif.image', self.handler.player.playerObject.getBifUrl(offset)) + self.bifImageControl.setPosition(bifx, 752) + + self.seekbarControl.setPosition(0, self.seekbarControl.getPosition()[1]) + if set_to_current: + self.seekbarControl.setWidth(w) + self.positionControl.setWidth(w) + else: + # we're seeking + if not self.selectedOffset: + return + + # current seek position below current offset? set the position bar's width to the current position of the + # seek and the seek bar to the current position of the video, to visually indicate the backwards-seeking + if self.selectedOffset < self.offset: + self.positionControl.setWidth(current_w) + self.seekbarControl.setWidth(w) + + # current seek position ahead of current offset? set the position bar's width to the current position of the + # video and the seek bar to the current position of the seek, to visually indicate the forwards-seeking + elif self.selectedOffset > self.offset: + self.seekbarControl.setPosition(current_w, self.seekbarControl.getPosition()[1]) + self.seekbarControl.setWidth(w - current_w) + # we may have "shortened" the width before, by seeking negatively, reset the position bar's width to + # the current video's position if that's the case + if self.positionControl.getWidth() < current_w: + self.positionControl.setWidth(current_w) + + else: + self.seekbarControl.setWidth(w) + self.positionControl.setWidth(w) + + def waitForBuffer(self): + # current filesize in bytes + size = float(self.handler.player.video.mediaChoice.part.size) + + # current buffer fill percentage + currentBufferPerc = int(xbmc.getInfoLabel("Player.ProgressCache")) - int(xbmc.getInfoLabel("Player.Progress")) + + # configured buffer size + bufferBytes = util.kcm.memorySize * 1024 * 1024 + + # wait for the full buffer or for 10% of the file at max + # a full buffer is typically 30% of the configured cache value + sensibleBufferPerc = min(bufferBytes / size * 100.0 / 2.8, 10) + + # can wait for buffer? + # we're relying on integer based percentages coming from kodi's internal ProgressCache. + # with a typical device buffer of 20-160 MB, this might be less than 1% of available buffer based on the playing + # media item. If we're below that value, wait for a defined amount of time instead of being smart. + if sensibleBufferPerc >= 1.0: + if currentBufferPerc < sensibleBufferPerc: + # pause + wasPlaying = False + if self.player.playState == self.player.STATE_PLAYING: + util.DEBUG_LOG("SeekDialog.buffer: Waiting for buffer to reach {} (is: {}), pausing" + .format(sensibleBufferPerc, currentBufferPerc)) + self.player.pause() + wasPlaying = True + + waitedFor = 0 + waitMax = util.advancedSettings.bufferWaitMax + waitExceeded = False + self.waitingForBuffer = True + self.showOSD(focusButton=False) + with busy.BusyClosableMsgContext() as bc: + # check for the buffer fill-state every 200ms + # this may be canceled by the usual actions; + # depending on who receives the cancel action, _abortBufferWait might be set by our onAction + # or by the busy window via the context manager + while not self._abortBufferWait and not bc.shouldClose and waitedFor < waitMax and \ + (int(xbmc.getInfoLabel("Player.ProgressCache")) - + int(xbmc.getInfoLabel("Player.Progress"))) < sensibleBufferPerc: + curBuf = int(xbmc.getInfoLabel("Player.ProgressCache")) - \ + int(xbmc.getInfoLabel("Player.Progress")) + + bc.setMessage("Buffer: {} %".format(int(curBuf / sensibleBufferPerc * 100))) + + xbmc.sleep(200) + waitedFor += 0.2 + + # report buffer state every 10 seconds + if int(waitedFor) > 0 and int(waitedFor) % 10 == 0: + util.DEBUG_LOG("SeekDialog.buffer: " + "Buffer filled {}/{}".format(curBuf, sensibleBufferPerc)) + + # buffer wait canceled via busy window + if bc.shouldClose: + self._abortBufferWait = True + + # buffer timed out + if waitedFor >= waitMax: + waitExceeded = True + + self.waitingForBuffer = False + + if waitExceeded or self._abortBufferWait: + if not self._abortBufferWait: + util.showNotification(util.T(32917, "Couldn't fill buffer in time ({}s)").format(waitMax), + header="Buffer") + self.stop() + return True + + if self.player.playState == self.player.STATE_PAUSED and wasPlaying: + # resume + util.DEBUG_LOG("SeekDialog.buffer: Buffer filled, resuming") + self.player.pause() + return True + else: + util.DEBUG_LOG("SeekDialog.buffer: Buffer already filled, not waiting for buffer") + + else: + wait = util.advancedSettings.bufferInsufficientWait + util.DEBUG_LOG("SeekDialog.buffer: Buffer is too small for us to see, waiting {} seconds".format(wait)) + self.waitingForBuffer = True + + wasPlaying = False + if self.player.playState == self.player.STATE_PLAYING: + self.player.pause() + wasPlaying = True + + with busy.BusyClosableMsgContext() as bc: + bc.setMessage("Buffering") + util.MONITOR.waitForAbort(wait) + self.waitingForBuffer = False + if self.player.playState == self.player.STATE_PAUSED and wasPlaying: + self.player.pause() + return True + + def onPlayBackResumed(self): + util.DEBUG_LOG("SeekDialog: OnPlaybackResumed") + if self._ignoreInput: + self._ignoreInput = False + + self.idleTime = None + self.ldTimer and self.syncTimeKeeper() + + def onAVChange(self): + util.DEBUG_LOG("SeekDialog: onAVChange") + + # wait for buffer if we're not expecting a seek + if not self.handler.seekOnStart and util.getSetting("slow_connection", False) and not self.waitingForBuffer: + # fixme: not sure why this is necessary, but something breaks when playing back a next item from playback + # that doesn't have a seek value. Adding a slight delay here fixes that. Timing issue? + xbmc.sleep(100) + self.tick(waitForBuffer=True) + return + + def onAVStarted(self): + util.DEBUG_LOG("SeekDialog: OnAVStarted") + if self._ignoreInput: + self._ignoreInput = False + + self.ldTimer and self.syncTimeKeeper() + + def onPlayBackStarted(self): + util.DEBUG_LOG("SeekDialog: OnPlaybackStarted") + if self._ignoreInput: + self._ignoreInput = False + + self.ldTimer and self.syncTimeKeeper() + + def onPlayBackPaused(self): + util.DEBUG_LOG("SeekDialog: OnPlaybackPaused") + self.idleTime = time.time() + + def onPlayBackSeek(self, stime, offset): + util.DEBUG_LOG("SeekDialog: OnPlaybackSeek") + self.idleTime = None + self.ldTimer and self.syncTimeKeeper() + + def onPlayBackStopped(self): + util.DEBUG_LOG("SeekDialog: OnPlayBackStopped") + self.killTimeKeeper() + + def onPlayBackEnded(self): + util.DEBUG_LOG("SeekDialog: OnPlayBackEnded") + self.killTimeKeeper() + + def onPlayBackFailed(self): + util.DEBUG_LOG("SeekDialog: OnPlayBackFailed") + self.killTimeKeeper() + + def syncTimeKeeper(self): + if not not self.handler.player.playerObject: + return + + self.timeKeeperTime = self.trueOffset()#int(self.handler.player.getTime() * 1000) + if not self.timeKeeper: + self.timeKeeper = plexapp.util.RepeatingCounterTimer(1.0, self.onTimeKeeperCallback) + self.onTimeKeeperCallback(tick=False) + self.timeKeeper.reset() + + def killTimeKeeper(self): + if self.timeKeeper: + try: + self.timeKeeper.cancel() + self.timeKeeper.join() + self.timeKeeper = None + except: + util.ERROR("Couldn't stop timeKeeper") + + def onTimeKeeperCallback(self, tick=True): + """ + called by playbackTimer periodically, sets playback time/ends in UI + """ + # we might be a little early on slower systems + if not self.started or not self.handler.player.playerObject: + return + + if self.stopPlaybackOnIdle: + if self.idleTime and time.time() - self.idleTime >= self.stopPlaybackOnIdle: + util.LOG("Player has been idle for {}s, stopping.".format(int(time.time() - self.idleTime))) + self.handler.player.stopAndWait() + return + + if not self.idleTime and xbmc.getCondVisibility('Player.Paused'): + self.idleTime = time.time() + + if tick and xbmc.getCondVisibility('Player.HasVideo + Player.Playing'): + self.timeKeeperTime += 1000 + + # Update buffer state in PPI if open and old Kodi version + if util.KODI_BUILD_NUMBER < 2090821 and self.getProperty('show.PPI'): + try: + cache = int(xbmc.getInfoLabel('Player.ProgressCache')) - int(xbmc.getInfoLabel('Player.Progress')) + self.setProperty('ppi.Buffered', str(cache)) + except: + pass + + # Only updateCurrent when not in DirectPlay mode. Otherwise the Kodi time functions will be used by the skin. + if self.isDirectPlay: + return + + self.updateCurrent( + update_position_control=not self._seeking and not self._applyingSeek, atOffset=self.timeKeeperTime) + + @property + def countingDownMarker(self): + return self._currentMarker and \ + self._currentMarker["countdown"] is not None and \ + self._currentMarker["countdown"] > 0 + + def displayMarkers(self, cancelTimer=False, immediate=False, onlyReturnIntroMD=False, setSkipped=False, + offset=None): + # intro/credits marker display logic + markerDef = self.getCurrentMarkerDef(offset=offset) + + if not markerDef: + # no marker to display, hide it + self.setProperty('show.markerSkip', '') + self.setProperty('show.markerSkip_OSDOnly', '') + + # this might be counter intuitive, but self._currentMarker is a reference to a dict + if self._currentMarker: + self._currentMarker["countdown"] = None + self._currentMarker = None + return False + + # getCurrentMarkerDef might have overridden the startTimeOffset, use that + startTimeOff = markerDef["overrideStartOff"] if markerDef["overrideStartOff"] is not None else \ + int(markerDef["marker"].startTimeOffset) + + markerAutoSkip = getattr(self, markerDef["markerAutoSkip"]) + markerAutoSkipped = markerDef["markerAutoSkipped"] + + sTOffWThres = startTimeOff + util.advancedSettings.autoSkipOffset * 1000 + + # we just want to return an early marker if we want to autoSkip it, so we can tell the handler to seekOnStart + if onlyReturnIntroMD and markerDef["marker_type"] == "intro" and markerAutoSkip: + if startTimeOff == 0 and not markerDef["markerAutoSkipped"]: + if setSkipped: + markerDef["markerAutoSkipped"] = True + return int(markerDef["marker"].endTimeOffset) + 1000 + return False + + if cancelTimer and self.countingDownMarker: + self._currentMarker["countdown"] = None + markerDef["markerAutoSkipped"] = True + setattr(self, markerDef["markerAutoSkipShownTimer"], None) + self.setProperty('show.markerSkip', '') + return False + + autoSkippingNow = markerDef \ + and markerAutoSkip \ + and not markerAutoSkipped \ + and not self._navigatedViaMarkerOrChapter \ + and (markerDef["countdown"] == 0 or startTimeOff == 0 or immediate) + # and (startTimeOff == 0 or sTOffWThres <= self.offset) \ + + # auto skip marker + # delay marker autoskip by autoSkipOffset to avoid cutting off content at the expense of being + # slightly too late + if autoSkippingNow: + markerDef["markerAutoSkipped"] = True + setattr(self, markerDef["markerAutoSkipShownTimer"], None) + self.setProperty('show.markerSkip', '') + self.setProperty('show.markerSkip_OSDOnly', '') + self.resetAutoSeekTimer(None) + final = getattr(markerDef["marker"], "final", False) + markerDef["countdown"] = None + + if final: + # final marker is _not_ at the end of video, seek and do nothing + if int(markerDef["marker"].endTimeOffset) < self.duration - FINAL_MARKER_NEGOFF: + target = int(markerDef["marker"].endTimeOffset) + util.DEBUG_LOG( + "MarkerAutoSkip: Skipping final marker, its endTime is too early, " + "though, seeking and playing back") + self.doSeek(target) + return False + + # tell plex we've arrived at the end of the video, playing back + self.handler.updateNowPlaying(True, state=self.player.STATE_STOPPED, time=self.duration - 1000) + + # go to next video immediately if on bingeMode + if self.handler.playlist and self.handler.playlist.hasNext() and self.bingeMode: + if not self.handler.queuingNext: + # skip final marker + util.DEBUG_LOG("MarkerAutoSkip: Skipping final marker, going to next video") + self.handler.ignoreTimelines = True + self.handler.queuingNext = True + self._ignoreTick = True + self._ignoreInput = True + self.killTimeKeeper() + self.player.stop() + return True + else: + util.DEBUG_LOG("MarkerAutoSkip: Skipping final marker, stopping") + self.stop() + return False + + util.DEBUG_LOG('MarkerAutoSkip: Skipping marker {}'.format(markerDef["marker"])) + self.doSeek(int(markerDef["marker"].endTimeOffset) + 1000) + return True + + # got a marker, display logic + # hide marker into OSD after a timeout + timer = getattr(self, markerDef["markerAutoSkipShownTimer"]) + + if timer is None or self.player.playState == self.player.STATE_PAUSED: + setattr(self, markerDef["markerAutoSkipShownTimer"], time.time()) + + else: + if timer + getattr(self, markerDef["markerSkipBtnTimeout"]) <= time.time(): + self.setProperty('show.markerSkip_OSDOnly', '1') + else: + self.setProperty('show.markerSkip_OSDOnly', '') + + # no marker auto skip or not yet auto skipped, normal display + if not markerAutoSkip or (markerAutoSkip and not markerAutoSkipped): + self.setProperty('show.markerSkip', '1') + # marker auto skip and already skipped - hide in OSD + elif markerAutoSkip and markerAutoSkipped: + self.setProperty('show.markerSkip_OSDOnly', '1') + + # set marker name, count down + if markerAutoSkip and not markerAutoSkipped: + if markerDef["countdown"] is None: + # reset countdown on new marker + if not self._currentMarker or self._currentMarker != markerDef or markerDef["countdown"] is None: + # fixme: round might not be right here, but who cares + markerDef["countdown"] = int(max(round((sTOffWThres - self.trueOffset()) / 1000.0) + 1, 1)) + + if self.player.playState == self.player.STATE_PLAYING and not self.osdVisible(): + markerDef["countdown"] -= 1 + + self.setProperty('marker.countdown', '1') + + if markerDef["countdown"] > 0: + markerName = "{} ({})".format(markerDef["autoSkipName"], markerDef["countdown"]) + else: + markerName = markerDef["autoSkipName"] + else: + markerName = markerDef["name"] + self.setProperty('skipMarkerName', markerName) + + # store current marker + self._currentMarker = markerDef + + # focus marker if OSD is hidden, last focus wasn't the marker button and we're not auto skipping this marker + if not self.osdVisible() and self.lastFocusID != self.SKIP_MARKER_BUTTON_ID and \ + not self.getProperty('show.markerSkip_OSDOnly') and self.getProperty('show.markerSkip') \ + and not markerAutoSkip: + self.setFocusId(self.SKIP_MARKER_BUTTON_ID) + + def tick(self, offset=None, waitForBuffer=False): + """ + Called ~1/s; can be wildly inaccurate. + """ + if waitForBuffer: + cont = self.waitForBuffer() + if not cont: + return + + if not self.initialized or self._ignoreTick: + return + + # invisibly sync low-drift timer to current playback every X seconds, as Player.getTime() can be wildly off + if self.ldTimer and not self.osdVisible() and self.timeKeeper and self.timeKeeper.ticks >= 60: + self.syncTimeKeeper() + + cancelTick = False + # don't auto skip while we're initializing and waiting for the handler to seek on start + if offset is None and not self.handler.seekOnStart: + cancelTick = self.displayMarkers() + + if cancelTick: + return + + if xbmc.getCondVisibility('Window.IsActive(busydialog) + !Player.Caching'): + util.DEBUG_LOG('SeekDialog: Possible stuck busy dialog - closing') + xbmc.executebuiltin('Dialog.Close(busydialog,1)') + + if not self.hasDialog and not self.playlistDialogVisible and self.osdVisible(): + if time.time() > self.timeout and not self._osdHideFast: + if not xbmc.getCondVisibility('Window.IsActive(videoosd) | Player.Rewinding | Player.Forwarding'): + self.hideOSD() + + # try insta-hiding the OSDs when playback was requested + elif self._osdHideFast: + xbmc.executebuiltin('Dialog.Close(videoosd,true)') + xbmc.executebuiltin('Dialog.Close(seekbar,true)') + if not xbmc.getCondVisibility('Window.IsActive(videoosd) | Player.Rewinding | Player.Forwarding'): + self.hideOSD() + + self._osdHideFast = False + + try: + self.offset = offset or int(self.handler.player.getTime() * 1000) + except RuntimeError: # Playback has stopped + self.resetSeeking() + return + + if offset or (self.autoSeekTimeout and time.time() >= self.autoSeekTimeout and + self.offset != self.selectedOffset): + self.resetAutoSeekTimer(None) + self.doSeek() + return True + + if self.isDirectPlay or not self.ldTimer: + self.updateCurrent(update_position_control=not self._seeking and not self._applyingSeek) + + @property + def playlistDialogVisible(self): + return self._playlistDialogVisible + + @playlistDialogVisible.setter + def playlistDialogVisible(self, value): + self._playlistDialogVisible = value + self.setProperty('playlist.visible', '1' if value else '') + + def showPlaylistDialog(self): + if not self.playlistDialog: + self.playlistDialog = PlaylistDialog.create(show=False, handler=self.handler) + + self.playlistDialogVisible = True + self.playlistDialog.doModal() + self.resetTimeout() + self.playlistDialogVisible = False + + def osdVisible(self): + return xbmc.getCondVisibility('Control.IsVisible(801)') + + def showOSD(self, focusButton=True): + self.setProperty('show.OSD', '1') + xbmc.executebuiltin('Dialog.Close(videoosd,true)') + if xbmc.getCondVisibility('Player.showinfo'): + xbmc.executebuiltin('Action(Info)') + + if focusButton: + self.setFocusId(self.PLAY_PAUSE_BUTTON_ID) + + def hideOSD(self, skipMarkerFocus=False, closing=False): + self.setProperty('show.OSD', '') + if closing: + return + + self.setFocusId(self.NO_OSD_BUTTON_ID) + if not skipMarkerFocus and self.getCurrentMarkerDef() and not self.getProperty('show.markerSkip_OSDOnly'): + self.setFocusId(self.SKIP_MARKER_BUTTON_ID) + + self.resetSeeking() + self._osdHideAnimationTimeout = time.time() + self.OSD_HIDE_ANIMATION_DURATION + + self._osdHideFast = False + if self.playlistDialog: + self.playlistDialog.doClose() + self.playlistDialogVisible = False + + +class PlaylistDialog(kodigui.BaseDialog): + xmlFile = 'script-plex-video_current_playlist.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + LI_AR16X9_THUMB_DIM = (178, 100) + LI_SQUARE_THUMB_DIM = (100, 100) + + PLAYLIST_LIST_ID = 101 + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + self.handler = kwargs.get('handler') + self.playlist = self.handler.playlist + + def onFirstInit(self): + self.handler.player.on('playlist.changed', self.playQueueCallback) + self.handler.player.on('session.ended', self.sessionEnded) + self.playlistListControl = kodigui.ManagedControlList(self, self.PLAYLIST_LIST_ID, 6) + self.fillPlaylist() + self.updatePlayingItem() + self.setFocusId(self.PLAYLIST_LIST_ID) + + def onClick(self, controlID): + if controlID == self.PLAYLIST_LIST_ID: + self.playlistListClicked() + + def playlistListClicked(self): + mli = self.playlistListControl.getSelectedItem() + if not mli: + return + self.handler.playAt(mli.pos()) + self.updatePlayingItem() + + def sessionEnded(self, **kwargs): + util.DEBUG_LOG('Video OSD: Session ended - closing') + self.doClose() + + def createListItem(self, pi): + if pi.type == 'episode': + return self.createEpisodeListItem(pi) + elif pi.type in ('movie', 'clip'): + return self.createMovieListItem(pi) + + def createEpisodeListItem(self, episode): + label2 = u'{0} \u2022 {1}'.format( + episode.grandparentTitle, + u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), episode.parentIndex, T(32311, 'E'), episode.index) + ) + mli = kodigui.ManagedListItem(episode.title, label2, + thumbnailImage=episode.thumb.asTranscodedImageURL(*self.LI_AR16X9_THUMB_DIM), + data_source=episode) + mli.setProperty('track.duration', util.durationToShortText(episode.duration.asInt())) + mli.setProperty('video', '1') + mli.setProperty('watched', episode.isWatched and '1' or '') + return mli + + def createMovieListItem(self, movie): + mli = kodigui.ManagedListItem(movie.title, movie.year, + thumbnailImage=movie.art.asTranscodedImageURL(*self.LI_AR16X9_THUMB_DIM), + data_source=movie) + mli.setProperty('track.duration', util.durationToShortText(movie.duration.asInt())) + mli.setProperty('video', '1') + mli.setProperty('watched', movie.isWatched and '1' or '') + return mli + + def playQueueCallback(self, **kwargs): + mli = self.playlistListControl.getSelectedItem() + pi = mli.dataSource + plexID = pi['comment'].split(':', 1)[0] + viewPos = self.playlistListControl.getViewPosition() + + self.fillPlaylist() + + for ni in self.playlistListControl: + if ni.dataSource['comment'].split(':', 1)[0] == plexID: + self.playlistListControl.selectItem(ni.pos()) + break + + xbmc.sleep(100) + + newViewPos = self.playlistListControl.getViewPosition() + if viewPos != newViewPos: + diff = newViewPos - viewPos + self.playlistListControl.shiftView(diff, True) + + def updatePlayingItem(self): + playing = self.handler.player.video.ratingKey + selectIndex = None + for (index, mli) in enumerate(self.playlistListControl): + isMLI = mli.dataSource.ratingKey == playing + mli.setProperty('playing', isMLI and '1' or '') + if isMLI: + selectIndex = index + + if selectIndex is not None: + xbmc.executebuiltin('Control.SetFocus({0}, {1})'.format(self.PLAYLIST_LIST_ID, selectIndex)) + + def fillPlaylist(self): + items = [] + idx = 1 + for pi in self.playlist.items(): + mli = self.createListItem(pi) + if mli: + mli.setProperty('track.number', str(idx)) + items.append(mli) + idx += 1 + + self.playlistListControl.reset() + self.playlistListControl.addItems(items) diff --git a/script.plexmod/lib/windows/settings.py b/script.plexmod/lib/windows/settings.py new file mode 100644 index 000000000..fae697e3e --- /dev/null +++ b/script.plexmod/lib/windows/settings.py @@ -0,0 +1,832 @@ +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui +from kodi_six import xbmcvfs +from . import kodigui +from . import windowutils + +from lib import util +from lib.util import T + +import plexnet +import sys + + +class Setting(object): + type = None + ID = None + label = None + desc = None + default = None + userAware = False + + def translate(self, val): + return str(val) + + def get(self): + return util.getSetting(self.ID, self.default) + + def set(self, val): + old = self.get() + setRet = util.setSetting(self.ID, val) + if old != val: + util.DEBUG_LOG('Setting: {0} - changed from [{1}] to [{2}]'.format(self.ID, old, val)) + plexnet.util.APP.trigger('change:{0}'.format(self.ID), value=val) + return setRet + + def valueLabel(self): + return self.translate(self.get()) + + def __repr__(self): + return ''.format(self.ID, self.get()) + + +class BasicSetting(Setting): + def __init__(self, ID, label, default, desc=''): + self.ID = ID + self.label = label + self.default = default + self.desc = desc + + def description(self, desc): + self.desc = desc + return self + + +class ListSetting(BasicSetting): + type = 'LIST' + options = () + + def translate(self, val): + return self.options[len(self.options) - 1 - val] + + def optionLabels(self): + return self.options + + def optionIndex(self): + return len(self.options) - 1 - self.get() + + def set(self, val): + BasicSetting.set(self, len(self.options) - 1 - val) + + +class QualitySetting(ListSetting): + options = ( + T(32001), + T(32002), + T(32003), + T(32004), + T(32005), + T(32006), + T(32007), + T(32008), + T(32009), + T(32010), + T(32011), + T(32012), + T(32013), + T(32014), + ) + + +class ThemeMusicSetting(ListSetting): + options = [ + T(32481), + ] + [T(32482) % {"percentage": 10+i} for i in range(0, 100, 10)] + + +class PlayedThresholdSetting(ListSetting): + options = ['{} %'.format(perc) for perc in range(70, 100, 5)] + + +class BoolSetting(BasicSetting): + type = 'BOOL' + + +class BoolUserSetting(BoolSetting): + """ + A user-aware BoolSetting + """ + userAware = True + + @property + def userAwareID(self): + return '{}.{}'.format(self.ID, plexnet.plexapp.ACCOUNT.ID) + + def get(self): + return util.getSetting(self.userAwareID, self.default) + + def set(self, val): + old = self.get() + if old != val: + util.DEBUG_LOG('Setting: {0} - changed from [{1}] to [{2}]'.format(self.userAwareID, old, val)) + plexnet.util.APP.trigger('change:{0}'.format(self.ID), key=self.userAwareID, value=val, skey=self.ID) + return util.setSetting(self.userAwareID, val) + + +class OptionsSetting(BasicSetting): + type = 'OPTIONS' + + def __init__(self, ID, label, default, options): + BasicSetting.__init__(self, ID, label, default) + self.options = options + + def translate(self, val): + for ID, label in self.options: + if ID == val: + return label + + def optionLabels(self): + return [o[1] for o in self.options] + + def optionIndex(self): + val = self.get() + for i, o in enumerate(self.options): + if val == o[0]: + return i + + return 0 + + +class BufferSetting(OptionsSetting): + def get(self): + return util.kcm.memorySize + + def set(self, val): + old = self.get() + if old != val: + util.DEBUG_LOG('Setting: {0} - changed from [{1}] to [{2}]'.format(self.ID, old, val)) + plexnet.util.APP.trigger('change:{0}'.format(self.ID), value=val) + + util.kcm.write(memorySize=val) + + +class ReadFactorSetting(OptionsSetting): + def get(self): + return util.kcm.readFactor + + def set(self, val): + old = self.get() + if old != val: + util.DEBUG_LOG('Setting: {0} - changed from [{1}] to [{2}]'.format(self.ID, old, val)) + plexnet.util.APP.trigger('change:{0}'.format(self.ID), value=val) + + util.kcm.write(readFactor=val) + + +class InfoSetting(BasicSetting): + type = 'INFO' + + def __init__(self, ID, label, info): + BasicSetting.__init__(self, ID, label, None) + self.info = info + + def valueLabel(self): + return self.info + + +class PlatformSetting(InfoSetting): + def __init__(self): + InfoSetting.__init__(self, None, None, None) + self.ID = 'platfom_version' + self.label = T(32410, 'Platform Version') + + def valueLabel(self): + plat = None + try: + if sys.version_info[0] >= 3: + from lib import distro + dist = distro.linux_distribution() + if dist and len(dist) > 1: + plat = u'{0} {1}'.format(dist[0], dist[1]) + else: + import platform + dist = platform. dist() + if dist and len(dist) > 1: + plat = u'{0} {1}'.format(dist[0], dist[1]) + else: + plat = platform.platform() + plat = u'{0} {1}'.format(plat[0], '.'.join(plat[1].split('.', 2)[:2])) + except: + util.ERROR() + + plat = plat.strip() + + if not plat: + if xbmc.getCondVisibility('System.Platform.Android'): + plat = 'Android' + elif xbmc.getCondVisibility('System.Platform.OSX'): + plat = 'OSX' + elif xbmc.getCondVisibility('System.Platform.Darwin'): + plat = 'Darwin' + elif xbmc.getCondVisibility('System.Platform.Linux.RaspberryPi'): + plat = 'Linux (RPi)' + elif xbmc.getCondVisibility('System.Platform.Linux'): + plat = 'Linux' + elif xbmc.getCondVisibility('System.Platform.Windows'): + plat = 'Windows' + + return plat or T(32411, 'Unknown') + + +class ServerVersionSetting(InfoSetting): + def valueLabel(self): + if not plexnet.plexapp.SERVERMANAGER.selectedServer: + return '' + + return plexnet.plexapp.SERVERMANAGER.selectedServer.rawVersion or '' + + +class IPSetting(BasicSetting): + type = 'IP' + + +class IntegerSetting(BasicSetting): + type = 'INTEGER' + + +class Settings(object): + SETTINGS = { + 'main': ( + T(32000, 'Main'), ( + BoolSetting( + 'auto_signin', T(32038, 'Automatically Sign In'), False + ).description( + T(32100, 'Skip user selection and pin entry on startup.') + ), + BoolSetting( + 'speedy_home_hubs2', T(33503, 'Use alternative hubs refresh'), False + ).description( + T( + 33504, + "Refreshes all hubs for all libraries after an item's watch-state has changed, instead of " + "only those likely affected. Use this if you find a hub that doesn't update properly." + ) + ), + BoolSetting( + 'search_use_kodi_kbd', T(32955, 'Use Kodi keyboard for searching'), False + ), + ThemeMusicSetting('theme_music', T(32480, 'Theme music'), 5), + PlayedThresholdSetting('played_threshold', T(33501, 'Video played threshold'), 1).description( + T( + 33502, + "Set this to the same value as your Plex server (Settings>Library>Video played threshold) to av" + "oid certain pitfalls, Default: 90 %" + ) + ) + ) + ), + 'video': ( + T(32053, 'Video'), ( + QualitySetting('local_quality', T(32020, 'Local Quality'), 13), + QualitySetting('remote_quality', T(32021, 'Remote Quality'), 13), + QualitySetting('online_quality', T(32022, 'Online Quality'), 13), + BoolSetting('playback_directplay', T(32025, 'Allow Direct Play'), True), + BoolSetting('playback_remux', T(32026, 'Allow Direct Stream'), True), + BoolSetting('allow_4k', T(32036, 'Allow 4K'), True).description( + T(32102, 'Enable this if your hardware can handle 4K playback. Disable it to force transcoding.') + ), + BoolSetting('allow_hevc', T(32037, 'Allow HEVC (h265)'), True).description( + T(32103, 'Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding.') + ) + ) + ), + 'audio': ( + T(32931, 'Audio/Subtitles'), ( + BoolSetting('audio_clamp_to_orig', T(32058, 'Never exceed original audio codec'), True).description( + T(32059, 'When transcoding audio, never exceed the original audio bitrate or channel ' + 'count on the same codec.') + ), + BoolSetting('audio_channels_kodi', T(32060, 'Use Kodi audio channels'), + False).description( + T(32061, 'When transcoding audio, target the audio channels set in Kodi.') + ), + OptionsSetting( + 'audio_force_ac3_cond', + T(32062, 'Transcode audio to AC3'), + 'never', + ( + ('never', T(32033, 'Never')), + ('always', T(32028, 'Always')), + ('2', '2.1+'), + ('5', '5.1+'), + ) + ).description( + T(32063, 'Transcode audio to AC3 in certain conditions (useful for passthrough).') + ), + BoolSetting('audio_ac3dts', T(32064, 'Treat DTS like AC3'), + True).description( + T(32065, 'When force AC3 settings are enabled, treat DTS the same as AC3 ' + '(useful for Optical passthrough)') + ), + OptionsSetting( + 'burn_subtitles', + T(32031, 'Burn-in Subtitles'), + 'auto', + (('auto', T(32030, 'Auto')), ('image', T(32029, 'Only Image Formats')), + ('always', T(32028, 'Always'))) + ), + BoolSetting('burn_ssa', T(32944, 'Burn-in SSA subtitles'), + True).description( + T(32945, 'When Direct Streaming instruct the Plex Server to burn in SSA/ASS subtitles (thus ' + 'transcoding the video stream). If disabled it will not touch the video stream, but ' + 'will convert the subtitle to unstyled text.') + ), + BoolSetting('forced_subtitles_override', T(32941, 'Forced subtitles fix'), + False).description( + T(32493, 'When a media file has a forced/foreign subtitle for a subtitle-enabled language, the Plex' + ' Media Server preselects it. This behaviour is usually not necessary and not configurable' + '. This setting fixes that by ignoring the PMSs decision and selecting the same language ' + 'without a forced flag if possible.') + ), + ) + ), + 'player': ( + T(32940, 'Player UI'), ( + BoolSetting('subtitle_downloads', T(32932, 'Show subtitle quick-actions button'), False).description( + T(32939, 'Only applies to video player UI')), + BoolSetting('video_show_ffwdrwd', T(32933, 'Show FFWD/RWD buttons'), False).description( + T(32939, 'Only applies to video player UI')), + BoolSetting('video_show_repeat', T(32934, 'Show repeat button'), False).description( + T(32939, 'Only applies to video player UI')), + BoolSetting('video_show_shuffle', T(32935, 'Show shuffle button'), False).description( + T(32939, 'Only applies to video player UI')), + OptionsSetting( + 'video_show_playlist', T(32936, 'Show playlist button'), 'eponly', + ( + ('always', T(32035, 'Always')), ('eponly', T(32938, 'Only for Episodes')), + ('never', T(32033, 'Never')) + ) + ).description(T(32939, 'Only applies to video player UI')), + OptionsSetting( + 'video_show_prevnext', T(32937, 'Show prev/next button'), 'eponly', + ( + ('always', T(32035, 'Always')), ('eponly', T(32938, 'Only for Episodes')), + ('never', T(32033, 'Never')) + ) + ).description(T(32939, 'Only applies to video player UI')), + ) + ), + 'player_user': ( + T(32631, 'Playback (user-specific)'), ( + BoolUserSetting( + 'post_play_auto', T(32039, 'Post Play Auto Play'), True + ).description( + T( + 32101, + "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatical" + "ly be played after a 15 second delay." + ) + ), + BoolUserSetting( + 'binge_mode', T(33618, 'TV binge-viewing mode'), False + ).description( + T(33619, 'Automatically skips episode intros, credits and tries to skip episode recaps. Doesn\'t ' + 'skip the intro of the first episode of a season.\n\nCan be disabled/enabled per TV show.') + ), + BoolUserSetting( + 'auto_skip_intro', T(32522, 'Automatically Skip Intro'), False + ).description( + T(32523, 'Automatically skip intros if available. Doesn\'t override enabled binge mode.') + ), + BoolUserSetting( + 'auto_skip_credits', T(32526, 'Auto Skip Credits'), False + ).description( + T(32527, 'Automatically skip credits if available. Doesn\'t override enabled binge mode.') + ), + BoolUserSetting( + 'show_intro_skip_early', T(33505, 'Show intro skip button early'), False + ).description( + T(33506, 'Show the intro skip button from the start of a video with an intro marker. The auto-skipp' + 'ing setting applies. Doesn\'t override enabled binge mode.') + ), + BoolUserSetting( + 'auto_skip_in_transcode', T(32948, 'Allow auto-skip when transcoding'), True + ).description( + T(32949, 'When transcoding/DirectStreaming, allow auto-skip functionality.') + ), + BoolUserSetting( + 'show_chapters', T(33601, 'Show video chapters'), True + ).description( + T(33602, 'If available, show video chapters from the video-file instead of the ' + 'timeline-big-seek-steps.') + ), + BoolUserSetting( + 'virtual_chapters', T(33603, 'Use virtual chapters'), True + ).description( + T(33604, 'When the above is enabled and no video chapters are available, simulate them by using the' + ' markers identified by the Plex Server (Intro, Credits).') + ), + ) + ), + 'network': ( + T(33624, 'Network'), ( + OptionsSetting( + 'allow_insecure', T(32032), 'never', + (('never', T(32033)), ('same_network', T(32034)), ('always', T(32035))) + ).description( + T(32104, 'When to connect to servers with no secure connections...') + ), + BoolSetting('smart_discover_local', T(33625, 'Smart LAN/local server discovery'), True) + .description( + T(33626, "Checks whether servers returned from Plex.tv are actually local/in your LAN. " + "For specific setups (e.g. Docker) Plex.tv might not properly detect a local " + "server.\n\nNOTE: Only works on Kodi 19 or above." + ) + ), + BoolSetting('prefer_local', T(33627, 'Prefer LAN/local servers over security'), False) + .description( + T(33628, "Prioritizes local connections over secure ones. Needs the proper setting in \"Allow " + "Insecure Connections\" and the Plex Server's \"Secure connections\" at \"Preferred\". " + "Can be used to enforce manual servers." + ) + ), + BoolSetting('gdm_discovery', T(32042, 'Server Discovery (GDM)'), False), + IPSetting('manual_ip_0', T(32044, 'Connection 1 IP'), ''), + IntegerSetting('manual_port_0', T(32045, 'Connection 1 Port'), 32400), + IPSetting('manual_ip_1', T(32046, 'Connection 2 IP'), ''), + IntegerSetting('manual_port_1', T(32047, 'Connection 2 Port'), 32400), + ) + ), + 'system': ( + T(33600, 'System'), ( + + BoolSetting('kiosk.mode', T(32043, 'Start Plex On Kodi Startup'), False), + BufferSetting('cache_size', + T(33613, 'Kodi Buffer Size (MB)'), + 20, + [(mem, '{} MB'.format(mem)) for mem in util.kcm.viableOptions]) + .description( + '{}{}'.format(T(33614, 'stub1').format( + util.kcm.free, util.kcm.recMax), + '' if util.kcm.useModernAPI else ' '+T(32954, 'stub2')) + ), + ReadFactorSetting('readfactor', + T(32922, 'Kodi Cache Readfactor'), + 4, + [(rf, str(rf)) for rf in util.kcm.readFactorOpts]) + .description( + T(32923, 'Sets the Kodi cache readfactor value. Default: {0}, recommended: {1}.' + 'With "Slow connection" enabled this will be set to {2}, as otherwise the cache doesn\'t' + 'fill fast/aggressively enough.').format(util.kcm.defRF, + "{}-{}".format(*util.kcm.recRFRange), + util.kcm.defRFSM) + ), + BoolSetting( + 'slow_connection', T(32915, 'Slow connection'), False + ).description("Use with a wonky/slow connection, e.g. in a hotel room. Adjusts the UI to visually " + "wait for item refreshes and waits for the buffer to fill when starting playback."), + OptionsSetting( + 'action_on_sleep', + T(32700, 'Action on Sleep event'), + 'none', + (('none', T(32702, 'Nothing')), ('stop', T(32703, 'Stop playback')), + ('quit', T(32704, 'Quit Kodi')), ('reboot', T(32426, 'Reboot')), + ('shutdown', T(32423, 'Shutdown')), + ('hibernate', T(32425, 'Hibernate')), ('suspend', T(32424, 'Suspend')), + ('cecstandby', T(32705, 'CEC Standby')), ('logoff', T(32421, 'Sign Out'))) + ).description(T(32701, 'When Kodi receives a sleep event from the system, run the following action.')), + OptionsSetting( + 'player_stop_on_idle', + T(32946, 'Stop video playback on idle after'), + 0, + ((0, T(32033, 'Never')), (30, '30s'), (60, '1m'), (120, '2m'), (300, '5m'), (600, '10m'), + (900, '15m'), (1200, '20m'), (1800, '30m'), (2700, '45m'), (3600, '1h'),) + ), + BoolSetting( + 'player_stop_on_screensaver', T(32947, 'Stop video playback on screensaver'), True + ), + BoolSetting('debug', T(32024, 'Debug Logging'), False), + ) + ), + 'privacy': ( + T(32051, 'Privacy'), + () + ), + 'about': ( + T(32052, 'About'), ( + InfoSetting('addon_version', T(32054, 'Addon Version'), util.ADDON.getAddonInfo('version')), + InfoSetting('kodi_version', T(32055, 'Kodi Version'), xbmc.getInfoLabel('System.BuildVersion')), + PlatformSetting(), + InfoSetting('screen_res', T(32056, 'Screen Resolution'), + xbmc.getInfoLabel('System.ScreenResolution').split('-')[0].strip()), + ServerVersionSetting('server_version', T(32057, 'Current Server Version'), None), + InfoSetting('addon_path', T(33616, 'Addon Path'), util.ADDON.getAddonInfo("path")), + InfoSetting('userdata_path', T(33617, 'Userdata/Profile Path'), + util.translatePath("special://profile")), + ) + ), + } + + SECTION_IDS = ('main', 'video', 'audio', 'player', 'player_user', 'network', 'system', 'about') + + def __getitem__(self, key): + return self.SETTINGS[key] + + +# enable AV1 setting if kodi nexus +if util.KODI_VERSION_MAJOR >= 20: + videoSettings = list(Settings.SETTINGS["video"]) + videoSettings[1] = tuple(list(videoSettings[1]) + [ + BoolSetting('allow_av1', T(32601, 'Allow AV1'), False).description( + T(32602, + 'Enable this if your hardware can handle AV1. Disable it to force transcoding.') + ) + ]) + Settings.SETTINGS["video"] = (videoSettings[0], videoSettings[1]) + + +class SettingsWindow(kodigui.BaseWindow, windowutils.UtilMixin): + xmlFile = 'script-plex-settings.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + SECTION_LIST_ID = 75 + SETTINGS_LIST_ID = 100 + OPTIONS_LIST_ID = 125 + TOP_GROUP_ID = 200 + + CLOSE_BUTTON_ID = 201 + PLAYER_STATUS_BUTTON_ID = 204 + + def onFirstInit(self): + self.settings = Settings() + self.sectionList = kodigui.ManagedControlList(self, self.SECTION_LIST_ID, 6) + self.settingsList = kodigui.ManagedControlList(self, self.SETTINGS_LIST_ID, 6) + self.optionsList = kodigui.ManagedControlList(self, self.OPTIONS_LIST_ID, 6) + + self.setProperty('heading', T(32343, 'Settings')) + self.showSections() + self.setFocusId(75) + self.lastSection = None + self.checkSection() + + def onAction(self, action): + try: + self.checkSection() + controlID = self.getFocusId() + if action in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + if self.getFocusId() == self.OPTIONS_LIST_ID: + self.setFocusId(self.SETTINGS_LIST_ID) + return + # elif not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.TOP_GROUP_ID)): + # self.setFocusId(self.TOP_GROUP_ID) + # return + elif action == xbmcgui.ACTION_MOVE_RIGHT and controlID == 150: + self.editSetting(from_right=True) + except: + util.ERROR() + + kodigui.BaseWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.SECTION_LIST_ID: + self.setFocusId(self.SETTINGS_LIST_ID) + elif controlID == self.SETTINGS_LIST_ID: + self.editSetting() + elif controlID == self.OPTIONS_LIST_ID: + self.changeSetting() + elif controlID == self.CLOSE_BUTTON_ID: + self.doClose() + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + + def checkSection(self): + mli = self.sectionList.getSelectedItem() + if not mli: + return + + if mli.dataSource == self.lastSection: + return + + self.lastSection = mli.dataSource + self.showSettings(self.lastSection) + self.setProperty('section.about', self.lastSection == 'about' and '1' or '') + util.DEBUG_LOG('Settings: Changed section ({0})'.format(self.lastSection)) + + def showSections(self): + items = [] + for sectionID in self.settings.SECTION_IDS: + label = self.settings[sectionID][0] + item = kodigui.ManagedListItem(label, data_source=sectionID) + items.append(item) + + self.sectionList.addItems(items) + + def showSettings(self, section): + settings = self.settings[section][1] + if not settings: + return self.settingsList.reset() + + items = [] + for setting in settings: + item = kodigui.ManagedListItem(setting.label, setting.type != 'BOOL' and setting.valueLabel() or '', data_source=setting) + item.setProperty('description', setting.desc) + if setting.type == 'BOOL': + item.setProperty('checkbox', '1') + item.setProperty('checkbox.checked', setting.get() and '1' or '') + elif setting.type == 'BUTTON': + item.setProperty('button', '1') + + if setting.userAware: + item.setProperty('useraware', '1') + + items.append(item) + + self.settingsList.reset() + self.settingsList.addItems(items) + + def editSetting(self, from_right=False): + mli = self.settingsList.getSelectedItem() + if not mli: + return + + setting = mli.dataSource + + if setting.type in ('LIST', 'OPTIONS'): + self.fillList(setting) + elif setting.type == 'BOOL' and not from_right: + self.toggleBool(mli, setting) + elif setting.type == 'IP' and not from_right: + self.editIP(mli, setting) + elif setting.type == 'INTEGER' and not from_right: + self.editInteger(mli, setting) + elif setting.type == 'BUTTON': + self.buttonDialog(mli, setting) + + def changeSetting(self): + optionItem = self.optionsList.getSelectedItem() + if not optionItem: + return + + mli = self.settingsList.getSelectedItem() + if not mli: + return + + setting = mli.dataSource + + if setting.type == 'LIST': + setting.set(optionItem.pos()) + mli.setLabel2(setting.valueLabel()) + elif setting.type == 'OPTIONS': + setting.set(optionItem.dataSource) + mli.setLabel2(setting.valueLabel()) + + self.setFocusId(self.SETTINGS_LIST_ID) + + def fillList(self, setting): + items = [] + if setting.type == 'LIST': + for label in setting.optionLabels(): + items.append(kodigui.ManagedListItem(label)) + elif setting.type == 'OPTIONS': + for ID, label in setting.options: + items.append(kodigui.ManagedListItem(label, data_source=ID)) + + self.optionsList.reset() + self.optionsList.addItems(items) + self.optionsList.selectItem(setting.optionIndex()) + self.setFocusId(self.OPTIONS_LIST_ID) + + def toggleBool(self, mli, setting): + setting.set(not setting.get()) + mli.setProperty('checkbox.checked', setting.get() and '1' or '') + + def editIP(self, mli, setting): + current = setting.get() + edit = True + if current: + edit = xbmcgui.Dialog().yesno( + T(32412, 'Edit Or Clear'), + T(32413, 'Edit IP address or clear the current setting?'), + nolabel=T(32414, 'Clear'), + yeslabel=T(32415, 'Edit') + ) + + if edit: + result = xbmcgui.Dialog().input(T(32416, 'Enter IP Address'), current, xbmcgui.INPUT_IPADDRESS) + if not result: + return + else: + result = '' + + setting.set(result) + mli.setLabel2(result) + + def editInteger(self, mli, setting): + result = xbmcgui.Dialog().input(T(32417, 'Enter Port Number'), str(setting.get()), xbmcgui.INPUT_NUMERIC) + if not result: + return + setting.set(int(result)) + mli.setLabel2(result) + + +class SelectDialog(kodigui.BaseDialog, util.CronReceiver): + xmlFile = 'script-plex-settings_select_dialog.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + OPTIONS_LIST_ID = 100 + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + self.heading = kwargs.get('heading') + self.options = kwargs.get('options') + self.choice = None + + def onFirstInit(self): + self.optionsList = kodigui.ManagedControlList(self, self.OPTIONS_LIST_ID, 8) + self.setProperty('heading', self.heading) + self.showOptions() + util.CRON.registerReceiver(self) + + def onAction(self, action): + try: + if not xbmc.getCondVisibility('Player.HasMedia'): + self.doClose() + return + except: + util.ERROR() + + kodigui.BaseDialog.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.OPTIONS_LIST_ID: + self.setChoice() + + def onClosed(self): + util.CRON.cancelReceiver(self) + + def tick(self): + if not xbmc.getCondVisibility('Player.HasMedia'): + self.doClose() + return + + def setChoice(self): + mli = self.optionsList.getSelectedItem() + if not mli: + return + + self.choice = self.options[self.optionsList.getSelectedPosition()][0] + self.doClose() + + def showOptions(self): + items = [] + for o in self.options: + item = kodigui.ManagedListItem(o[1], data_source=o[0]) + items.append(item) + + self.optionsList.reset() + self.optionsList.addItems(items) + + self.setFocusId(self.OPTIONS_LIST_ID) + + +def showOptionsDialog(heading, options): + w = SelectDialog.open(heading=heading, options=options) + choice = w.choice + del w + return choice + + +def showAudioDialog(video): + options = [(s, s.getTitle()) for s in video.audioStreams] + choice = showOptionsDialog(T(32048, 'Audio'), options) + if choice is None: + return + + video.selectStream(choice) + + +def showSubtitlesDialog(video): + options = [(s, s.getTitle()) for s in video.subtitleStreams] + options.insert(0, (plexnet.plexstream.NoneStream(), 'None')) + choice = showOptionsDialog(T(32396, 'Subtitles'), options) + if choice is None: + return + + video.selectStream(choice) + + +def showQualityDialog(video): + options = [(13 - i, T(l)) for (i, l) in enumerate((32001, 32002, 32003, 32004, 32005, 32006, 32007, 32008, 32009, + 32010, 32011))] + + choice = showOptionsDialog(T(32397, 'Quality'), options) + if choice is None: + return + + video.settings.setPrefOverride('local_quality', choice) + video.settings.setPrefOverride('remote_quality', choice) + video.settings.setPrefOverride('online_quality', choice) + + +def openWindow(): + w = SettingsWindow.open() + del w diff --git a/script.plexmod/lib/windows/signin.py b/script.plexmod/lib/windows/signin.py new file mode 100644 index 000000000..444933662 --- /dev/null +++ b/script.plexmod/lib/windows/signin.py @@ -0,0 +1,146 @@ +from __future__ import absolute_import +from kodi_six import xbmcgui +from . import kodigui +from lib import util + + +class Background(kodigui.BaseWindow): + xmlFile = 'script-plex-signin_background.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + +class SignInMessage(kodigui.BaseWindow): + xmlFile = 'script-plex-signin_blank.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + SCREEN_BUTTON_ID = 100 + + def __init__(self, *args, **kwargs): + self.message = kwargs.get('message') + kodigui.BaseWindow.__init__(self, *args, **kwargs) + + def onFirstInit(self): + self.setProperty('message', self.message) + + def onClick(self, controlID): + if controlID == self.SCREEN_BUTTON_ID: + self.doClose() + + +class SignInPlexPass(kodigui.BaseWindow): + xmlFile = 'script-plex-plex_pass.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + RETRY_BUTTON_ID = 100 + + def __init__(self, *args, **kwargs): + self.retry = False + kodigui.BaseWindow.__init__(self, *args, **kwargs) + + def onAction(self, action): + if action == xbmcgui.ACTION_SELECT_ITEM: + self.retry = True + self.doClose() + + def onClick(self, controlID): + if controlID == self.RETRY_BUTTON_ID: + self.retry = True + self.doClose() + + +class PreSignInWindow(kodigui.BaseWindow): + xmlFile = 'script-plex-pre_signin.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + SIGNIN_BUTTON_ID = 100 + + def __init__(self, *args, **kwargs): + self.doSignin = False + kodigui.BaseWindow.__init__(self, *args, **kwargs) + + def onFirstInit(self): + self.signinButton = self.getControl(self.SIGNIN_BUTTON_ID) + + def onAction(self, action): + if action == xbmcgui.ACTION_SELECT_ITEM: + self.doSignin = True + self.doClose() + + def onClick(self, controlID): + if controlID == self.SIGNIN_BUTTON_ID: + self.doSignin = True + self.doClose() + + +class PinLoginWindow(kodigui.BaseWindow): + xmlFile = 'script-plex-pin_login.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + def __init__(self, *args, **kwargs): + self.abort = False + kodigui.BaseWindow.__init__(self, *args, **kwargs) + + def setPin(self, pin): + self.setProperty('pin.image.0', 'script.plex/sign_in/digits/{0}.png'.format(pin[0].upper())) + self.setProperty('pin.image.1', 'script.plex/sign_in/digits/{0}.png'.format(pin[1].upper())) + self.setProperty('pin.image.2', 'script.plex/sign_in/digits/{0}.png'.format(pin[2].upper())) + self.setProperty('pin.image.3', 'script.plex/sign_in/digits/{0}.png'.format(pin[3].upper())) + + def setLinking(self): + self.setProperty('linking', '1') + self.setProperty('pin.image.0', '') + self.setProperty('pin.image.1', '') + self.setProperty('pin.image.2', '') + self.setProperty('pin.image.3', '') + + def onAction(self, action): + try: + if action == xbmcgui.ACTION_NAV_BACK or action == xbmcgui.ACTION_PREVIOUS_MENU: + self.abort = True + except: + util.ERROR() + + kodigui.BaseWindow.onAction(self, action) + + +class ExpiredWindow(kodigui.BaseWindow): + xmlFile = 'script-plex-refresh_code.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + REFRESH_BUTTON_ID = 100 + + def __init__(self, *args, **kwargs): + self.refresh = False + kodigui.BaseWindow.__init__(self, *args, **kwargs) + + def onFirstInit(self): + self.refreshButton = self.getControl(self.REFRESH_BUTTON_ID) + + def onClick(self, controlID): + if controlID == self.REFRESH_BUTTON_ID: + self.refresh = True + self.doClose() diff --git a/script.plexmod/lib/windows/slidehshow.py b/script.plexmod/lib/windows/slidehshow.py new file mode 100644 index 000000000..d25f8b670 --- /dev/null +++ b/script.plexmod/lib/windows/slidehshow.py @@ -0,0 +1,108 @@ +import time +import random + +from . import kodigui + +from lib import util +from plexnet import plexapp + +class Slideshow(kodigui.BaseWindow, util.CronReceiver): + xmlFile = 'script-plex-slideshow.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + TIME_BETWEEN_IMAGES = 15 + TIME_HIDE_TITLE_IN_QUIZ = 5 + TIME_DISPLAY_MOVE = 60 + + CONTROL_INFO_GROUP = 100 + + def __init__(self, *args, **kwargs): + kodigui.BaseWindow.__init__(self, *args, **kwargs) + self.timeBetweenImages = self.TIME_BETWEEN_IMAGES + self.timeBetweenDisplayMove = self.TIME_DISPLAY_MOVE + self.timeTitleIsHidden = self.TIME_HIDE_TITLE_IN_QUIZ + self.quizMode = util.advancedSettings.screensaverQuiz + self.initialized = False + + def onFirstInit(self): + self.setProperty('clock', '') + self.setProperty('title', '') + self.setProperty('thumb', '') + self.setProperty('align', '0') + + self.infoGroupControl = self.getControl(self.CONTROL_INFO_GROUP) + + util.CRON.registerReceiver(self) + self.timeFormat = util.timeFormat.replace(":%S", "") + self.lastTime = '' + self.displayPosition = 0 + self.changeTime = time.time() - 1 + self.displayMoveTime = time.time() + self.timeBetweenDisplayMove + self.revealTitleTime = None + + self.selectedServer = plexapp.SERVERMANAGER.selectedServer + self.index = -1 + self.images = [] + + self.initialized = True + + def tick(self): + if not self.initialized: + return + + currentTime = time.time() + timestr = time.strftime(self.timeFormat, time.localtime(currentTime)) + if not util.padHour and timestr[0] == "0" and timestr[1] != ":": + timestr = timestr[1:] + + if currentTime > self.changeTime: + nextIndex = self.index + 1 + + if nextIndex >= len(self.images): + if self.selectedServer != None: + self.images = self.selectedServer.library.randomArts(); + util.DEBUG_LOG('[SS] Fetched {0} items'.format(len(self.images))) + nextIndex = 0 + + if len(self.images) == 0: + title = 'No Images' + url = '' + else: + image = self.images[nextIndex] + title = image.get('title') + key = image.get('key') + url = self.selectedServer.getImageTranscodeURL(key, self.width, self.height) + if not self.quizMode: + self.setProperty('title', title) + else: + self.setProperty('title', '') + self.quizTitle = title + self.revealTitleTime = currentTime + self.timeTitleIsHidden + self.setProperty('thumb', url) + + self.index = nextIndex + self.changeTime = time.time() + self.timeBetweenImages + + if self.revealTitleTime != None and currentTime > self.revealTitleTime: + self.setProperty('title', self.quizTitle) + self.revealTitleTime = None + + if currentTime > self.displayMoveTime: + oldDisplayPosition = self.displayPosition + self.displayPosition = (oldDisplayPosition + random.randint(1, 3)) % 4 + + if (oldDisplayPosition&2) != (self.displayPosition&2): + self.setProperty('align', str((self.displayPosition&2)>>1)) + + if (oldDisplayPosition&1) != (self.displayPosition&1): + newY = self.height - self.infoGroupControl.getY() - self.infoGroupControl.getHeight() + self.infoGroupControl.setPosition(self.infoGroupControl.getX(), newY) + + self.displayMoveTime = currentTime + self.timeBetweenDisplayMove + + if timestr != self.lastTime: + self.setProperty('clock', timestr) \ No newline at end of file diff --git a/script.plexmod/lib/windows/subitems.py b/script.plexmod/lib/windows/subitems.py new file mode 100644 index 000000000..13204429b --- /dev/null +++ b/script.plexmod/lib/windows/subitems.py @@ -0,0 +1,647 @@ +from __future__ import absolute_import +import gc + +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui + +from lib import colors +from lib import util +from lib import metadata +from lib import player + +from plexnet import playlist +from plexnet.util import INTERFACE + +from . import busy +from . import episodes +from . import tracks +from . import opener +from . import info +from . import musicplayer +from . import videoplayer +from . import dropdown +from . import windowutils +from . import search +from . import pagination +from . import playbacksettings + +from lib.util import T +from .mixins import SeasonsMixin + + +class RelatedPaginator(pagination.BaseRelatedPaginator): + def getData(self, offset, amount): + return self.parentWindow.mediaItem.getRelated(offset=offset, limit=amount) + + +class ShowWindow(kodigui.ControlledWindow, windowutils.UtilMixin, SeasonsMixin, playbacksettings.PlaybackSettingsMixin): + xmlFile = 'script-plex-seasons.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + EXTRA_DIM = util.scaleResolution(329, 185) + RELATED_DIM = util.scaleResolution(268, 397) + ROLES_DIM = util.scaleResolution(334, 334) + + SUB_ITEM_LIST_ID = 400 + + EXTRA_LIST_ID = 401 + RELATED_LIST_ID = 402 + ROLES_LIST_ID = 403 + + OPTIONS_GROUP_ID = 200 + + HOME_BUTTON_ID = 201 + SEARCH_BUTTON_ID = 202 + PLAYER_STATUS_BUTTON_ID = 204 + + PROGRESS_IMAGE_ID = 250 + + INFO_BUTTON_ID = 301 + PLAY_BUTTON_ID = 302 + SHUFFLE_BUTTON_ID = 303 + OPTIONS_BUTTON_ID = 304 + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + self.mediaItem = kwargs.get('media_item') + self.parentList = kwargs.get('parent_list') + self.cameFrom = kwargs.get('came_from') + self.mediaItems = None + self.exitCommand = None + self.lastFocusID = None + self.lastNonOptionsFocusID = None + self.initialized = False + self.relatedPaginator = None + + def doClose(self): + self.relatedPaginator = None + kodigui.ControlledWindow.doClose(self) + + def onFirstInit(self): + self.subItemListControl = kodigui.ManagedControlList(self, self.SUB_ITEM_LIST_ID, 5) + self.extraListControl = kodigui.ManagedControlList(self, self.EXTRA_LIST_ID, 5) + self.relatedListControl = kodigui.ManagedControlList(self, self.RELATED_LIST_ID, 5) + self.rolesListControl = kodigui.ManagedControlList(self, self.ROLES_LIST_ID, 5) + self.progressImageControl = self.getControl(self.PROGRESS_IMAGE_ID) + + self.setup() + self.initialized = True + + self.setFocusId(self.PLAY_BUTTON_ID) + + def onInit(self): + super(ShowWindow, self).onInit() + if self.mediaItem.theme and (not self.cameFrom or self.cameFrom != self.mediaItem.ratingKey) \ + and not util.getSetting("slow_connection", False): + self.cameFrom = self.mediaItem.ratingKey + volume = self.mediaItem.settings.getThemeMusicValue() + if volume > 0: + player.PLAYER.playBackgroundMusic(self.mediaItem.theme.asURL(True), volume, + self.mediaItem.ratingKey) + + def setup(self): + self.mediaItem.reload(includeExtras=1, includeExtrasCount=10) + + self.relatedPaginator = RelatedPaginator(self.relatedListControl, leaf_count=int(self.mediaItem.relatedCount), + parent_window=self) + + self.updateProperties() + self.setBoolProperty("initialized", True) + self.fill() + hasPrev = self.fillExtras() + hasPrev = self.fillRelated(hasPrev) + self.fillRoles(hasPrev) + + def updateProperties(self): + self.setProperty('title', self.mediaItem.title) + self.setProperty('summary', self.mediaItem.summary) + self.setProperty('thumb', self.mediaItem.defaultThumb.asTranscodedImageURL(*self.THUMB_DIMS[self.mediaItem.type]['main.thumb'])) + self.updateBackgroundFrom(self.mediaItem) + self.setProperty('duration', util.durationToText(self.mediaItem.fixedDuration())) + self.setProperty('info', '') + self.setProperty('date', self.mediaItem.year) + + self.setProperty('extras.header', T(32305, 'Extras')) + self.setProperty('related.header', T(32306, 'Related Shows')) + + if self.mediaItem.creator: + self.setProperty('directors', u'{0} {1}'.format(T(32418, 'Creator').upper(), self.mediaItem.creator)) + elif self.mediaItem.studio: + self.setProperty('directors', u'{0} {1}'.format(T(32386, 'Studio').upper(), self.mediaItem.studio)) + + cast = u' / '.join([r.tag for r in self.mediaItem.roles()][:5]) + castLabel = T(32419, 'Cast').upper() + self.setProperty('writers', cast and u'{0} {1}'.format(castLabel, cast) or '') + + genres = self.mediaItem.genres() + self.setProperty('info', genres and (u' / '.join([g.tag for g in genres][:3])) or '') + + self.setProperties(('rating.stars', 'rating', 'rating.image', 'rating2', 'rating2.image'), '') + + if self.mediaItem.userRating: + stars = str(int(round((self.mediaItem.userRating.asFloat() / 10) * 5))) + self.setProperty('rating.stars', stars) + + if self.mediaItem.ratingImage: + rating = self.mediaItem.rating + audienceRating = self.mediaItem.audienceRating + if self.mediaItem.ratingImage.startswith('rottentomatoes:'): + rating = '{0}%'.format(int(rating.asFloat() * 10)) + if audienceRating: + audienceRating = '{0}%'.format(int(audienceRating.asFloat() * 10)) + + self.setProperty('rating', rating) + self.setProperty('rating.image', 'script.plex/ratings/{0}.png'.format(self.mediaItem.ratingImage.replace('://', '/'))) + if self.mediaItem.audienceRatingImage: + self.setProperty('rating2', audienceRating) + self.setProperty('rating2.image', 'script.plex/ratings/{0}.png'.format(self.mediaItem.audienceRatingImage.replace('://', '/'))) + else: + self.setProperty('rating', self.mediaItem.rating) + + sas = self.mediaItem.selectedAudioStream() + self.setProperty('audio', sas and sas.getTitle() or 'None') + + sss = self.mediaItem.selectedSubtitleStream( + forced_subtitles_override=util.getSetting("forced_subtitles_override", False)) + self.setProperty('subtitles', sss and sss.getTitle() or 'None') + + leafcount = self.mediaItem.leafCount.asFloat() + if leafcount: + width = (int((self.mediaItem.viewedLeafCount.asInt() / leafcount) * self.width)) or 1 + self.progressImageControl.setWidth(width) + + def onAction(self, action): + try: + controlID = self.getFocusId() + + if not controlID and self.lastFocusID and not action == xbmcgui.ACTION_MOUSE_MOVE: + self.setFocusId(self.lastFocusID) + + if action == xbmcgui.ACTION_CONTEXT_MENU: + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + self.lastNonOptionsFocusID = self.lastFocusID + self.setFocusId(self.OPTIONS_GROUP_ID) + return + else: + if self.lastNonOptionsFocusID: + self.setFocusId(self.lastNonOptionsFocusID) + self.lastNonOptionsFocusID = None + return + + elif action in(xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format( + self.OPTIONS_GROUP_ID)) and \ + (not util.advancedSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU): + if self.getProperty('on.extras'): + self.setFocusId(self.OPTIONS_GROUP_ID) + return + + if action == xbmcgui.ACTION_LAST_PAGE and xbmc.getCondVisibility('ControlGroup(300).HasFocus(0)'): + next(self) + elif action == xbmcgui.ACTION_NEXT_ITEM: + self.setFocusId(300) + next(self) + elif action == xbmcgui.ACTION_FIRST_PAGE and xbmc.getCondVisibility('ControlGroup(300).HasFocus(0)'): + self.prev() + elif action == xbmcgui.ACTION_PREV_ITEM: + self.setFocusId(300) + self.prev() + + if action == xbmcgui.ACTION_MOVE_UP and (controlID == self.SUB_ITEM_LIST_ID or + self.INFO_BUTTON_ID <= controlID <= self.OPTIONS_BUTTON_ID): + self.updateBackgroundFrom(self.mediaItem) + + if controlID == self.RELATED_LIST_ID: + if self.relatedPaginator.boundaryHit: + self.relatedPaginator.paginate() + return + elif action in (xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_MOVE_RIGHT): + self.updateBackgroundFrom(self.relatedListControl.getSelectedItem().dataSource) + + except: + util.ERROR() + + kodigui.ControlledWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.HOME_BUTTON_ID: + self.goHome() + elif controlID == self.SUB_ITEM_LIST_ID: + self.subItemListClicked() + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + elif controlID == self.EXTRA_LIST_ID: + self.openItem(self.extraListControl) + elif controlID == self.RELATED_LIST_ID: + self.openItem(self.relatedListControl) + elif controlID == self.ROLES_LIST_ID: + self.roleClicked() + elif controlID == self.INFO_BUTTON_ID: + self.infoButtonClicked() + elif controlID == self.PLAY_BUTTON_ID: + self.playButtonClicked() + elif controlID == self.SHUFFLE_BUTTON_ID: + self.shuffleButtonClicked() + elif controlID == self.OPTIONS_BUTTON_ID: + self.optionsButtonClicked() + elif controlID == self.SEARCH_BUTTON_ID: + self.searchButtonClicked() + + def onFocus(self, controlID): + self.lastFocusID = controlID + + if 399 < controlID < 500: + self.setProperty('hub.focus', str(controlID - 400)) + + if controlID == self.RELATED_LIST_ID: + self.updateBackgroundFrom(self.relatedListControl.getSelectedItem().dataSource) + + if xbmc.getCondVisibility('ControlGroup(50).HasFocus(0) + ControlGroup(300).HasFocus(0)'): + self.setProperty('on.extras', '') + elif xbmc.getCondVisibility('ControlGroup(50).HasFocus(0) + !ControlGroup(300).HasFocus(0)'): + self.setProperty('on.extras', '1') + + if player.PLAYER.bgmPlaying and player.PLAYER.handler.currentlyPlaying != self.mediaItem.ratingKey: + player.PLAYER.stopAndWait() + + def getMediaItems(self): + return False + + def next(self): + if not self._next(): + return + self.setup() + + __next__ = next + + @busy.dialog() + def _next(self): + if self.parentList: + mli = self.parentList.getListItemByDataSource(self.mediaItem) + if not mli: + return False + + pos = mli.pos() + 1 + if not self.parentList.positionIsValid(pos): + pos = 0 + + self.mediaItem = self.parentList.getListItem(pos).dataSource + else: + if not self.getMediaItems(): + return False + + if self.mediaItem not in self.mediaItems: + return False + + pos = self.mediaItems.index(self.mediaItem) + pos += 1 + if pos >= len(self.mediaItems): + pos = 0 + + self.mediaItem = self.mediaItems[pos] + + return True + + def prev(self): + if not self._prev(): + return + self.setup() + + @busy.dialog() + def _prev(self): + if self.parentList: + mli = self.parentList.getListItemByDataSource(self.mediaItem) + if not mli: + return False + + pos = mli.pos() - 1 + if pos < 0: + pos = self.parentList.size() - 1 + + self.mediaItem = self.parentList.getListItem(pos).dataSource + else: + if not self.getMediaItems(): + return False + + if self.mediaItem not in self.mediaItems: + return False + + pos = self.mediaItems.index(self.mediaItem) + pos -= 1 + if pos < 0: + pos = len(self.mediaItems) - 1 + + self.mediaItem = self.mediaItems[pos] + + return True + + def searchButtonClicked(self): + self.processCommand(search.dialog(self, section_id=self.mediaItem.getLibrarySectionId() or None)) + + def openItem(self, control=None, item=None): + if not item: + mli = control.getSelectedItem() + if not mli: + return + item = mli.dataSource + + self.processCommand(opener.open(item)) + + def subItemListClicked(self): + mli = self.subItemListControl.getSelectedItem() + if not mli: + return + + update = False + + w = None + if self.mediaItem.type == 'show': + w = episodes.EpisodesWindow.open(season=mli.dataSource, show=self.mediaItem, parent_list=self.subItemListControl) + update = True + elif self.mediaItem.type == 'artist': + w = tracks.AlbumWindow.open(album=mli.dataSource, parent_list=self.subItemListControl) + + if not mli: + return + + if not mli.dataSource.exists(): + self.subItemListControl.removeItem(mli.pos()) + + if not self.subItemListControl.size(): + self.closeWithCommand(w.exitCommand) + del w + gc.collect(2) + return + + if update: + mli.setProperty('unwatched.count', not mli.dataSource.isWatched and str(mli.dataSource.unViewedLeafCount) or '') + self.mediaItem.reload(includeRelated=1, includeRelatedCount=10, includeExtras=1, includeExtrasCount=10) + self.updateProperties() + + try: + self.processCommand(w.exitCommand) + finally: + del w + gc.collect(2) + + def infoButtonClicked(self): + fallback = 'script.plex/thumb_fallbacks/{0}.png'.format(self.mediaItem.type == 'show' and 'show' or 'music') + genres = u' / '.join([g.tag for g in util.removeDups(self.mediaItem.genres())][:6]) + + w = info.InfoWindow.open( + title=self.mediaItem.title, + sub_title=genres, + thumb=self.mediaItem.defaultThumb, + thumb_fallback=fallback, + info=self.mediaItem.summary, + background=self.getProperty('background'), + is_square=bool(isinstance(self, ArtistWindow)), + video=self.mediaItem + ) + del w + util.garbageCollect() + + def playButtonClicked(self, shuffle=False): + items = self.mediaItem.all() + pl = playlist.LocalPlaylist(items, self.mediaItem.getServer()) + resume = False + if not shuffle and self.mediaItem.type == 'show': + resume = self.getPlaylistResume(pl, items, self.mediaItem.title) + if resume is None: + return + + pl.shuffle(shuffle, first=True) + videoplayer.play(play_queue=pl, resume=resume) + + def shuffleButtonClicked(self): + self.playButtonClicked(shuffle=True) + + def optionsButtonClicked(self): + options = [] + oldBingeModeValue = None + if xbmc.getCondVisibility('Player.HasAudio + MusicPlayer.HasNext'): + options.append({'key': 'play_next', 'display': 'Play Next'}) + + if self.mediaItem.type != 'artist': + if self.mediaItem.isWatched: + options.append({'key': 'mark_unwatched', 'display': T(32318, 'Mark Unplayed')}) + else: + options.append({'key': 'mark_watched', 'display': T(32319, 'Mark Played')}) + + if self.mediaItem.type == "show": + if options: + options.append(dropdown.SEPARATOR) + + options.append({'key': 'playback_settings', 'display': T(32925, 'Playback Settings')}) + + # if xbmc.getCondVisibility('Player.HasAudio') and self.section.TYPE == 'artist': + # options.append({'key': 'add_to_queue', 'display': 'Add To Queue'}) + + # if False: + # options.append({'key': 'add_to_playlist', 'display': 'Add To Playlist'}) + + options.append(dropdown.SEPARATOR) + + options.append({'key': 'to_section', 'display': u'Go to {0}'.format(self.mediaItem.getLibrarySectionTitle())}) + pos = (880, 618) + + choice = dropdown.showDropdown(options, pos, close_direction='left') + if not choice: + return + + if choice['key'] == 'play_next': + xbmc.executebuiltin('PlayerControl(Next)') + elif choice['key'] == 'mark_watched': + self.mediaItem.markWatched() + self.updateItems() + self.updateProperties() + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'mark_unwatched': + self.mediaItem.markUnwatched() + self.updateItems() + self.updateProperties() + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'to_section': + self.goHome(self.mediaItem.getLibrarySectionId()) + elif choice['key'] == 'playback_settings': + self.playbackSettings(self.mediaItem, pos, False) + + def roleClicked(self): + mli = self.rolesListControl.getSelectedItem() + if not mli: + return + + sectionRoles = busy.widthDialog(mli.dataSource.sectionRoles, '') + + if not sectionRoles: + util.DEBUG_LOG('No sections found for actor') + return + + if len(sectionRoles) > 1: + x, y = self.getRoleItemDDPosition() + + options = [{'role': r, 'display': r.reasonTitle} for r in sectionRoles] + choice = dropdown.showDropdown(options, (x, y), pos_is_bottom=True, close_direction='bottom') + + if not choice: + return + + role = choice['role'] + else: + role = sectionRoles[0] + + self.processCommand(opener.open(role)) + + def getRoleItemDDPosition(self): + y = 980 + if xbmc.getCondVisibility('Control.IsVisible(500)'): + y += 360 + if xbmc.getCondVisibility('Control.IsVisible(501)'): + y += 360 + if xbmc.getCondVisibility('Control.IsVisible(502)'): + y += 520 + if xbmc.getCondVisibility('!String.IsEmpty(Window.Property(on.extras))'): + y -= 300 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),0) + Control.IsVisible(500)'): + y -= 500 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),1) + Control.IsVisible(501)'): + y -= 360 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),1) + Control.IsVisible(502)'): + y -= 500 + + focus = int(xbmc.getInfoLabel('Container(403).Position')) + + x = ((focus + 1) * 304) - 100 + return x, y + + def updateItems(self): + self.fill(update=True) + + def createListItem(self, obj): + mli = kodigui.ManagedListItem( + obj.title or '', + thumbnailImage=obj.defaultThumb.asTranscodedImageURL(*self.THUMB_DIMS[self.mediaItem.type]['item.thumb']), + data_source=obj + ) + return mli + + @busy.dialog() + def fill(self, update=False): + self.fillSeasons(self.mediaItem, update=update) + + def fillExtras(self): + items = [] + idx = 0 + + if not self.mediaItem.extras: + self.extraListControl.reset() + return False + + for extra in self.mediaItem.extras(): + mli = kodigui.ManagedListItem( + extra.title or '', + metadata.EXTRA_MAP.get(extra.extraType.asInt(), ''), + thumbnailImage=extra.thumb.asTranscodedImageURL(*self.EXTRA_DIM), + data_source=extra + ) + + if mli: + mli.setProperty('index', str(idx)) + mli.setProperty( + 'thumb.fallback', 'script.plex/thumb_fallbacks/{0}.png'.format(extra.type in ('show', 'season', 'episode') and 'show' or 'movie') + ) + items.append(mli) + idx += 1 + + if not items: + return False + + self.extraListControl.reset() + self.extraListControl.addItems(items) + return True + + def fillRelated(self, has_prev=False): + if not self.relatedPaginator.leafCount: + self.relatedListControl.reset() + return has_prev + + self.setProperty('divider.{0}'.format(self.RELATED_LIST_ID), has_prev and '1' or '') + + items = self.relatedPaginator.paginate() + + if not items: + return False + + return True + + def fillRoles(self, has_prev=False): + items = [] + idx = 0 + if not self.mediaItem.roles: + self.rolesListControl.reset() + return has_prev + + self.setProperty('divider.{0}'.format(self.ROLES_LIST_ID), has_prev and '1' or '') + + for role in self.mediaItem.roles(): + mli = kodigui.ManagedListItem(role.tag, role.role, thumbnailImage=role.thumb.asTranscodedImageURL(*self.ROLES_DIM), data_source=role) + mli.setProperty('index', str(idx)) + items.append(mli) + idx += 1 + + self.rolesListControl.reset() + self.rolesListControl.addItems(items) + return True + + +class ArtistWindow(ShowWindow): + xmlFile = 'script-plex-artist.xml' + + SUB_ITEM_LIST_ID = 101 + + def onFirstInit(self): + self.subItemListControl = kodigui.ManagedControlList(self, self.SUB_ITEM_LIST_ID, 5) + + self.setup() + + self.setFocusId(self.PLAY_BUTTON_ID) + + def setup(self): + self.updateProperties() + self.fill() + + def playButtonClicked(self, shuffle=False): + pl = playlist.LocalPlaylist(self.mediaItem.all(), self.mediaItem.getServer(), self.mediaItem) + pl.startShuffled = shuffle + w = musicplayer.MusicPlayerWindow.open(track=pl.current(), playlist=pl) + del w + + def updateProperties(self): + self.setProperty('summary', self.mediaItem.summary) + self.setProperty('thumb', self.mediaItem.defaultThumb.asTranscodedImageURL(*self.THUMB_DIMS[self.mediaItem.type]['main.thumb'])) + self.updateBackgroundFrom(self.mediaItem) + + @busy.dialog() + def fill(self): + self.mediaItem.reload(includeRelated=1, includeRelatedCount=10, includeExtras=1, includeExtrasCount=10) + self.setProperty('artist.title', self.mediaItem.title) + genres = u' / '.join([g.tag for g in util.removeDups(self.mediaItem.genres())][:6]) + self.setProperty('artist.genre', genres) + items = [] + idx = 0 + for album in sorted(self.mediaItem.albums(), key=lambda x: x.year): + mli = self.createListItem(album) + if mli: + mli.setProperty('index', str(idx)) + mli.setProperty('year', album.year) + mli.setProperty('thumb.fallback', 'script.plex/thumb_fallbacks/music.png') + items.append(mli) + idx += 1 + + self.subItemListControl.reset() + self.subItemListControl.addItems(items) diff --git a/script.plexmod/lib/windows/tracks.py b/script.plexmod/lib/windows/tracks.py new file mode 100644 index 000000000..90dc43043 --- /dev/null +++ b/script.plexmod/lib/windows/tracks.py @@ -0,0 +1,348 @@ +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui +from . import kodigui + +from lib import colors +from lib import util + +from plexnet import playlist + +from . import busy +from . import musicplayer +from . import dropdown +from . import windowutils +from . import opener +from . import search + +from lib.util import T + + +class AlbumWindow(kodigui.ControlledWindow, windowutils.UtilMixin): + xmlFile = 'script-plex-album.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + THUMB_AR16X9_DIM = util.scaleResolution(178, 100) + THUMB_SQUARE_DIM = util.scaleResolution(630, 630) + + TRACKS_LIST_ID = 101 + LIST_OPTIONS_BUTTON_ID = 111 + + OPTIONS_GROUP_ID = 200 + + HOME_BUTTON_ID = 201 + SEARCH_BUTTON_ID = 202 + PLAYER_STATUS_BUTTON_ID = 204 + + PLAY_BUTTON_ID = 301 + SHUFFLE_BUTTON_ID = 302 + OPTIONS_BUTTON_ID = 303 + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + self.album = kwargs.get('album') + self.parentList = kwargs.get('parentList') + self.albums = None + self.exitCommand = None + + def onFirstInit(self): + self.trackListControl = kodigui.ManagedControlList(self, self.TRACKS_LIST_ID, 5) + + self.setup() + self.setFocusId(self.TRACKS_LIST_ID) + try: + self.checkForHeaderFocus(xbmcgui.ACTION_MOVE_DOWN) + except AttributeError: + raise util.NoDataException + + def setup(self): + self.updateProperties() + self.fillTracks() + + def onAction(self, action): + controlID = self.getFocusId() + try: + if action == xbmcgui.ACTION_LAST_PAGE and xbmc.getCondVisibility('ControlGroup(300).HasFocus(0)'): + self.next() + elif action == xbmcgui.ACTION_NEXT_ITEM: + self.next() + elif action == xbmcgui.ACTION_FIRST_PAGE and xbmc.getCondVisibility('ControlGroup(300).HasFocus(0)'): + self.prev() + elif action == xbmcgui.ACTION_PREV_ITEM: + self.prev() + + if controlID == self.TRACKS_LIST_ID: + self.checkForHeaderFocus(action) + if controlID == self.LIST_OPTIONS_BUTTON_ID and self.checkOptionsAction(action): + return + elif action == xbmcgui.ACTION_CONTEXT_MENU: + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + self.setFocusId(self.OPTIONS_GROUP_ID) + return + # elif action in(xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): + # if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + # self.setFocusId(self.OPTIONS_GROUP_ID) + # return + except: + util.ERROR() + + kodigui.ControlledWindow.onAction(self, action) + + def checkOptionsAction(self, action): + if action == xbmcgui.ACTION_MOVE_UP: + mli = self.trackListControl.getSelectedItem() + if not mli: + return False + pos = mli.pos() - 1 + if self.trackListControl.positionIsValid(pos): + self.setFocusId(self.TRACKS_LIST_ID) + self.trackListControl.selectItem(pos) + return True + elif action == xbmcgui.ACTION_MOVE_DOWN: + mli = self.trackListControl.getSelectedItem() + if not mli: + return False + pos = mli.pos() + 1 + if self.trackListControl.positionIsValid(pos): + self.setFocusId(self.TRACKS_LIST_ID) + self.trackListControl.selectItem(pos) + return True + + return False + + def onClick(self, controlID): + if controlID == self.HOME_BUTTON_ID: + self.goHome() + elif controlID == self.TRACKS_LIST_ID: + self.trackPanelClicked() + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + elif controlID == self.PLAY_BUTTON_ID: + self.playButtonClicked() + elif controlID == self.SHUFFLE_BUTTON_ID: + self.shuffleButtonClicked() + elif controlID == self.OPTIONS_BUTTON_ID: + self.optionsButtonClicked() + elif controlID == self.LIST_OPTIONS_BUTTON_ID: + mli = self.trackListControl.getSelectedItem() + if mli: + self.optionsButtonClicked(mli) + elif controlID == self.SEARCH_BUTTON_ID: + self.searchButtonClicked() + + def getAlbums(self): + if not self.albums: + self.albums = self.album.artist().albums() + + if not self.albums: + return False + + return True + + def next(self): + if not self._next(): + return + self.setup() + + @busy.dialog() + def _next(self): + if self.parentList: + mli = self.parentList.getListItemByDataSource(self.album) + if not mli: + return False + + pos = mli.pos() + 1 + if not self.parentList.positionIsValid(pos): + pos = 0 + + self.album = self.parentList.getListItem(pos).dataSource + else: + if not self.getAlbums(): + return False + + if self.album not in self.albums: + return False + + pos = self.albums.index(self.album) + pos += 1 + if pos >= len(self.albums): + pos = 0 + + self.album = self.albums[pos] + + return True + + def prev(self): + if not self._prev(): + return + self.setup() + + @busy.dialog() + def _prev(self): + if self.parentList: + mli = self.parentList.getListItemByDataSource(self.album) + if not mli: + return False + + pos = mli.pos() - 1 + if pos < 0: + pos = self.parentList.size() - 1 + + self.album = self.parentList.getListItem(pos).dataSource + else: + if not self.getAlbums(): + return False + + if self.album not in self.albums: + return False + + pos = self.albums.index(self.album) + pos -= 1 + if pos < 0: + pos = len(self.albums) - 1 + + self.album = self.albums[pos] + + return True + + def searchButtonClicked(self): + self.processCommand(search.dialog(self, section_id=self.album.getLibrarySectionId() or None)) + + def shuffleButtonClicked(self): + self.playButtonClicked(shuffle=True) + + def optionsButtonClicked(self, item=None): + options = [] + + if item: + if item.dataSource.isWatched: + options.append({'key': 'mark_unwatched', 'display': T(32318, 'Mark Unplayed')}) + else: + options.append({'key': 'mark_watched', 'display': T(32319, 'Mark Played')}) + + # if False: + # options.append({'key': 'add_to_playlist', 'display': '[COLOR FF808080]Add To Playlist[/COLOR]'}) + else: + if xbmc.getCondVisibility('Player.HasAudio + MusicPlayer.HasNext'): + options.append({'key': 'play_next', 'display': T(32325, 'Play Next')}) + + # if xbmc.getCondVisibility('Player.HasAudio') and self.section.TYPE == 'artist': + # options.append({'key': 'add_to_queue', 'display': 'Add To Queue'}) + + if options: + options.append(dropdown.SEPARATOR) + + options.append({'key': 'to_artist', 'display': T(32301, 'Go to Artist')}) + options.append({'key': 'to_section', 'display': T(32302, u'Go to {0}').format(self.album.getLibrarySectionTitle())}) + + pos = (460, 1106) + bottom = True + setDropdownProp = False + if item: + viewPos = self.trackListControl.getViewPosition() + if viewPos > 6: + pos = (1490, 312 + (viewPos * 100)) + bottom = True + else: + pos = (1490, 167 + (viewPos * 100)) + bottom = False + setDropdownProp = True + choice = dropdown.showDropdown(options, pos, pos_is_bottom=bottom, close_direction='right', set_dropdown_prop=setDropdownProp) + if not choice: + return + + if choice['key'] == 'play_next': + xbmc.executebuiltin('PlayerControl(Next)') + elif choice['key'] == 'mark_watched': + media = item and item.dataSource or self.album + media.markWatched() + self.updateItems(item) + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'mark_unwatched': + media = item and item.dataSource or self.album + media.markUnwatched() + self.updateItems(item) + util.MONITOR.watchStatusChanged() + elif choice['key'] == 'to_artist': + self.processCommand(opener.open(self.album.parentRatingKey)) + elif choice['key'] == 'to_section': + self.goHome(self.album.getLibrarySectionId()) + + def checkForHeaderFocus(self, action): + if action in (xbmcgui.ACTION_MOVE_UP, xbmcgui.ACTION_PAGE_UP): + if self.trackListControl.getSelectedItem().getProperty('is.header'): + xbmc.executebuiltin('Action(up)') + if action in (xbmcgui.ACTION_MOVE_DOWN, xbmcgui.ACTION_PAGE_DOWN, xbmcgui.ACTION_MOVE_LEFT, xbmcgui.ACTION_MOVE_RIGHT): + if self.trackListControl.getSelectedItem().getProperty('is.header'): + xbmc.executebuiltin('Action(down)') + + def updateItems(self, item=None): + if item: + self.album.reload() + item.setProperty('watched', item.dataSource.isWatched and '1' or '') + else: + self.fillTracks() + + def playButtonClicked(self, shuffle=False): + pl = playlist.LocalPlaylist(self.album.all(), self.album.getServer()) + pl.startShuffled = shuffle + self.openWindow(musicplayer.MusicPlayerWindow, track=pl.current(), playlist=pl) + + def trackPanelClicked(self): + mli = self.trackListControl.getSelectedItem() + if not mli: + return + + self.openWindow(musicplayer.MusicPlayerWindow, track=mli.dataSource, album=self.album) + + def updateProperties(self): + self.setProperty( + 'background', + util.backgroundFromArt(self.album.art, width=self.width, height=self.height) + ) + self.setProperty('album.thumb', self.album.thumb.asTranscodedImageURL(*self.THUMB_SQUARE_DIM)) + self.setProperty('artist.title', self.album.parentTitle or '') + self.setProperty('album.title', self.album.title) + + def createListItem(self, obj): + mli = kodigui.ManagedListItem(obj.title, data_source=obj) + mli.setProperty('track.number', str(obj.index) or '') + mli.setProperty('track.duration', util.simplifiedTimeDisplay(obj.duration.asInt())) + return mli + + #@busy.dialog() + def fillTracks(self): + items = [] + idx = 0 + multiDisc = 0 + + for track in self.album.tracks(): + disc = track.parentIndex.asInt() + if disc > 1: + if not multiDisc: + items.insert(0, kodigui.ManagedListItem(u'{0} 1'.format(T(32420, 'Disc').upper()), properties={'is.header': '1'})) + + if disc != multiDisc: + items[-1].setProperty('is.footer', '1') + multiDisc = disc + items.append(kodigui.ManagedListItem('{0} {1}'.format(T(32420, 'Disc').upper(), disc), properties={'is.header': '1'})) + + mli = self.createListItem(track) + if mli: + mli.setProperty('track.ID', track.ratingKey) + mli.setProperty('index', str(idx)) + mli.setProperty('artist', self.album.parentTitle) + mli.setProperty('disc', str(disc)) + mli.setProperty('album', self.album.title) + mli.setProperty('number', '{0:0>2}'.format(track.index)) + items.append(mli) + idx += 1 + + if items: + items[-1].setProperty('is.footer', '1') + + self.trackListControl.replaceItems(items) diff --git a/script.plexmod/lib/windows/userselect.py b/script.plexmod/lib/windows/userselect.py new file mode 100644 index 000000000..c659276a9 --- /dev/null +++ b/script.plexmod/lib/windows/userselect.py @@ -0,0 +1,234 @@ +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui + +from . import kodigui +from . import dropdown +from . import busy + +from lib import util, image, backgroundthread +from plexnet import plexapp + +from lib.util import T + + +class UserThumbTask(backgroundthread.Task): + def setup(self, users, callback): + self.users = users + self.callback = callback + return self + + def run(self): + for user in self.users: + if self.isCanceled(): + return + + thumb, back = image.getImage(user.thumb, user.id) + self.callback(user, thumb, back) + + +class UserSelectWindow(kodigui.BaseWindow): + xmlFile = 'script-plex-user_select.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + USER_LIST_ID = 101 + PIN_ENTRY_GROUP_ID = 400 + SHUTDOWN_BUTTON_ID = 500 + + def __init__(self, *args, **kwargs): + self.task = None + self.selected = False + kodigui.BaseWindow.__init__(self, *args, **kwargs) + + def onFirstInit(self): + self.userList = kodigui.ManagedControlList(self, self.USER_LIST_ID, 6) + + self.start() + + def onAction(self, action): + try: + ID = action.getId() + if 57 < ID < 68: + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)): + item = self.userList.getSelectedItem() + if not item.dataSource.isProtected: + return + self.setFocusId(self.PIN_ENTRY_GROUP_ID) + self.pinEntryClicked(ID + 142) + return + elif 142 <= ID <= 149: # JumpSMS action + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)): + item = self.userList.getSelectedItem() + if not item.dataSource.isProtected: + return + self.setFocusId(self.PIN_ENTRY_GROUP_ID) + self.pinEntryClicked(ID + 60) + return + elif ID in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_BACKSPACE): + item = self.userList.getSelectedItem() + if xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)): + if item.getProperty('editing.pin'): + self.pinEntryClicked(211) + else: + self.setFocusId(self.USER_LIST_ID) + return + # return to selected user + self.selected = 'cancel' + self.doClose() + return + except: + util.ERROR() + + kodigui.BaseWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.USER_LIST_ID: + item = self.userList.getSelectedItem() + if item.dataSource.isProtected: + self.setFocusId(self.PIN_ENTRY_GROUP_ID) + else: + self.userSelected(item) + elif 200 < controlID < 212: + self.pinEntryClicked(controlID) + elif controlID == self.SHUTDOWN_BUTTON_ID: + self.shutdownClicked() + + def onFocus(self, controlID): + if controlID == self.USER_LIST_ID: + item = self.userList.getSelectedItem() + item.setProperty('editing.pin', '') + + def userThumbCallback(self, user, thumb, back): + item = self.userList.getListItemByDataSource(user) + if item: + item.setThumbnailImage(thumb) + item.setProperty('back.image', back) + + def start(self): + self.setProperty('busy', '1') + try: + users = plexapp.ACCOUNT.homeUsers + + items = [] + selectIndex = None + for idx, user in enumerate(users): + # thumb, back = image.getImage(user.thumb, user.id) + # mli = kodigui.ManagedListItem(user.title, thumbnailImage=thumb, data_source=user) + mli = kodigui.ManagedListItem(user.title, user.title[0].upper(), data_source=user) + mli.setProperty('pin', user.title) + # mli.setProperty('back.image', back) + mli.setProperty('protected', user.isProtected and '1' or '') + mli.setProperty('admin', user.isAdmin and '1' or '') + + if plexapp.ACCOUNT.ID == user.id: + selectIndex = idx + + items.append(mli) + + self.userList.addItems(items) + + self.task = UserThumbTask().setup(users, self.userThumbCallback) + backgroundthread.BGThreader.addTask(self.task) + + self.setFocusId(self.USER_LIST_ID) + + if selectIndex is not None: + self.userList.setSelectedItemByPos(selectIndex) + + self.setProperty('initialized', '1') + finally: + self.setProperty('busy', '') + + def shutdownClicked(self): + options = [] + options.append({'key': 'sign_out', 'display': T(32421, 'Sign Out')}) + options.append({'key': 'exit', 'display': T(32422, 'Exit')}) + if util.getSetting('kiosk.mode', False): + if xbmc.getCondVisibility('System.CanPowerDown'): + options.append({'key': 'shutdown', 'display': T(32423, 'Shutdown')}) + if xbmc.getCondVisibility('System.CanSuspend'): + options.append({'key': 'suspend', 'display': T(32424, 'Suspend')}) + if xbmc.getCondVisibility('System.CanHibernate'): + options.append({'key': 'hibernate', 'display': T(32425, 'Hibernate')}) + if xbmc.getCondVisibility('System.CanReboot'): + options.append({'key': 'reboot', 'display': T(32426, 'Reboot')}) + + with self.propertyContext('dropdown'): + choice = dropdown.showDropdown(options, (60, 101)) + if not choice: + return + + if choice['key'] == 'sign_out': + self.selected = 'signout' + self.doClose() + elif choice['key'] == 'exit': + self.doClose() + elif choice['key'] == 'shutdown': + xbmc.executebuiltin('Powerdown()') + elif choice['key'] == 'suspend': + xbmc.executebuiltin('Suspend()') + elif choice['key'] == 'hibernate': + xbmc.executebuiltin('Hibernate()') + elif choice['key'] == 'reboot': + xbmc.executebuiltin('Reset()') + + def pinEntryClicked(self, controlID): + item = self.userList.getSelectedItem() + if item.getProperty('editing.pin'): + pin = item.getProperty('editing.pin') + else: + pin = '' + + if len(pin) > 3: + return + + if controlID < 210: + pin += str(controlID - 200) + elif controlID == 210: + pin += '0' + elif controlID == 211: + pin = pin[:-1] + + if pin: + item.setProperty('pin', ' '.join(list(u"\u2022" * len(pin)))) + item.setProperty('editing.pin', pin) + if len(pin) > 3: + self.userSelected(item, pin) + else: + item.setProperty('pin', item.dataSource.title) + item.setProperty('editing.pin', '') + + @busy.dialog() + def userSelected(self, item, pin=None): + user = item.dataSource + # xbmc.sleep(500) + util.DEBUG_LOG('Home user selected: {0}'.format(user)) + + from lib import plex + with plex.CallbackEvent(plexapp.util.APP, 'account:response') as e: + if plexapp.ACCOUNT.switchHomeUser(user.id, pin) and plexapp.ACCOUNT.switchUser: + util.DEBUG_LOG('Waiting for user change...') + else: + e.close() + item.setProperty('pin', item.dataSource.title) + item.setProperty('editing.pin', '') + util.messageDialog(T(32427, 'Failed'), T(32926, 'Wrong pin entered!')) + return + + self.selected = True + self.doClose() + + def finished(self): + if self.task: + self.task.cancel() + + +def start(): + w = UserSelectWindow.open() + selected = w.selected + del w + return selected diff --git a/script.plexmod/lib/windows/videoplayer.py b/script.plexmod/lib/windows/videoplayer.py new file mode 100644 index 000000000..0e36426cd --- /dev/null +++ b/script.plexmod/lib/windows/videoplayer.py @@ -0,0 +1,596 @@ +from __future__ import absolute_import +import time +import threading +import math + +from kodi_six import xbmc +from kodi_six import xbmcgui + +from . import kodigui +from . import windowutils +from . import opener +from . import busy +from . import search +from . import dropdown +from . import pagination + +from lib import util +from lib import player +from lib import colors + +from lib.util import T + + +PASSOUT_PROTECTION_DURATION_SECONDS = 7200 +PASSOUT_LAST_VIDEO_DURATION_MILLIS = 1200000 + + +class RelatedPaginator(pagination.BaseRelatedPaginator): + def readyForPaging(self): + return self.parentWindow.postPlayInitialized + + def getData(self, offset, amount): + return (self.parentWindow.prev or self.parentWindow.next).getRelated(offset=offset, limit=amount) + + +class OnDeckPaginator(pagination.MCLPaginator): + def readyForPaging(self): + return self.parentWindow.postPlayInitialized + + thumbFallback = lambda self, rel: 'script.plex/thumb_fallbacks/{0}.png'.format( + rel.type in ('show', 'season', 'episode') and 'show' or 'movie') + + def prepareListItem(self, data, mli): + mli.setProperty('progress', util.getProgressImage(mli.dataSource)) + mli.setProperty('unwatched', not mli.dataSource.isWatched and '1' or '') + + if data.type in 'episode': + mli.setLabel2( + u'{0}{1} \u2022 {2}{3}'.format(T(32310, 'S'), data.parentIndex, T(32311, 'E'), data.index)) + else: + mli.setLabel2(data.year) + + def createListItem(self, ondeck): + title = ondeck.grandparentTitle or ondeck.title + if ondeck.type == 'episode': + thumb = ondeck.thumb.asTranscodedImageURL(*self.parentWindow.ONDECK_DIM) + else: + thumb = ondeck.defaultArt.asTranscodedImageURL(*self.parentWindow.ONDECK_DIM) + + mli = kodigui.ManagedListItem(title or '', thumbnailImage=thumb, data_source=ondeck) + if mli: + return mli + + def getData(self, offset, amount): + return (self.parentWindow.prev or self.parentWindow.next).sectionOnDeck(offset=offset, limit=amount) + + +class VideoPlayerWindow(kodigui.ControlledWindow, windowutils.UtilMixin): + xmlFile = 'script-plex-video_player.xml' + path = util.ADDON.getAddonInfo('path') + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + NEXT_DIM = util.scaleResolution(537, 303) + PREV_DIM = util.scaleResolution(462, 259) + ONDECK_DIM = util.scaleResolution(329, 185) + RELATED_DIM = util.scaleResolution(268, 397) + ROLES_DIM = util.scaleResolution(334, 334) + + OPTIONS_GROUP_ID = 200 + + PREV_BUTTON_ID = 101 + NEXT_BUTTON_ID = 102 + + ONDECK_LIST_ID = 400 + RELATED_LIST_ID = 401 + ROLES_LIST_ID = 403 + + HOME_BUTTON_ID = 201 + SEARCH_BUTTON_ID = 202 + + PLAYER_STATUS_BUTTON_ID = 204 + + def __init__(self, *args, **kwargs): + kodigui.ControlledWindow.__init__(self, *args, **kwargs) + windowutils.UtilMixin.__init__(self) + self.playQueue = kwargs.get('play_queue') + self.video = kwargs.get('video') + self.resume = bool(kwargs.get('resume')) + + self.postPlayMode = False + self.prev = None + self.playlist = None + self.handler = None + self.next = None + self.videos = None + self.trailer = None + self.aborted = True + self.timeout = None + self.passoutProtection = 0 + self.postPlayInitialized = False + self.relatedPaginator = None + self.onDeckPaginator = None + self.lastFocusID = None + self.lastNonOptionsFocusID = None + self.playBackStarted = False + + def doClose(self): + util.DEBUG_LOG('VideoPlayerWindow: Closing') + self.timeout = None + self.relatedPaginator = None + self.onDeckPaginator = None + kodigui.ControlledWindow.doClose(self) + player.PLAYER.handler.sessionEnded() + + def onFirstInit(self): + player.PLAYER.on('session.ended', self.sessionEnded) + player.PLAYER.on('av.started', self.playerPlaybackStarted) + player.PLAYER.on('post.play', self.postPlay) + player.PLAYER.on('change.background', self.changeBackground) + + self.onDeckListControl = kodigui.ManagedControlList(self, self.ONDECK_LIST_ID, 5) + self.relatedListControl = kodigui.ManagedControlList(self, self.RELATED_LIST_ID, 5) + self.rolesListControl = kodigui.ManagedControlList(self, self.ROLES_LIST_ID, 5) + + util.DEBUG_LOG('VideoPlayerWindow: Starting session (ID: {0})'.format(id(self))) + self.resetPassoutProtection() + self.play(resume=self.resume) + + def onReInit(self): + self.setBackground() + + def onAction(self, action): + try: + if self.postPlayMode: + controlID = self.getFocusId() + + self.cancelTimer() + self.resetPassoutProtection() + if action in(xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_CONTEXT_MENU): + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.OPTIONS_GROUP_ID)): + if not util.advancedSettings.fastBack or action == xbmcgui.ACTION_CONTEXT_MENU: + self.lastNonOptionsFocusID = self.lastFocusID + self.setFocusId(self.OPTIONS_GROUP_ID) + return + else: + if self.lastNonOptionsFocusID and action == xbmcgui.ACTION_CONTEXT_MENU: + self.setFocusId(self.lastNonOptionsFocusID) + self.lastNonOptionsFocusID = None + return + + if action in(xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_PREVIOUS_MENU): + self.doClose() + return + + if action in (xbmcgui.ACTION_NEXT_ITEM, xbmcgui.ACTION_PLAYER_PLAY): + self.playVideo() + elif action == xbmcgui.ACTION_PREV_ITEM: + self.playVideo(prev=True) + elif action == xbmcgui.ACTION_STOP: + self.doClose() + + if controlID == self.RELATED_LIST_ID: + if self.relatedPaginator.boundaryHit: + self.relatedPaginator.paginate() + return + + elif controlID == self.ONDECK_LIST_ID: + if self.onDeckPaginator.boundaryHit: + self.onDeckPaginator.paginate() + return + except: + util.ERROR() + + kodigui.ControlledWindow.onAction(self, action) + + def playerPlaybackStarted(self, *args, **kwargs): + self.playBackStarted = True + + def onClick(self, controlID): + if not self.postPlayMode: + return + + timeoutCanceled = False + if util.advancedSettings.postplayCancel: + timeoutCanceled = bool(self.timeout) + self.cancelTimer() + + if controlID == self.HOME_BUTTON_ID: + self.goHome() + elif controlID == self.ONDECK_LIST_ID: + self.openItem(self.onDeckListControl) + elif controlID == self.RELATED_LIST_ID: + self.openItem(self.relatedListControl) + elif controlID == self.ROLES_LIST_ID: + self.roleClicked() + elif controlID == self.PREV_BUTTON_ID: + self.playVideo(prev=True) + elif controlID == self.NEXT_BUTTON_ID: + if not timeoutCanceled: + self.playVideo() + elif controlID == self.PLAYER_STATUS_BUTTON_ID: + self.showAudioPlayer() + elif controlID == self.SEARCH_BUTTON_ID: + self.searchButtonClicked() + + def onFocus(self, controlID): + if not self.postPlayMode: + return + + self.lastFocusID = controlID + + if 399 < controlID < 500: + self.setProperty('hub.focus', str(controlID - 400)) + else: + self.setProperty('hub.focus', '') + + if xbmc.getCondVisibility('Control.HasFocus(101) | Control.HasFocus(102) | ControlGroup(200).HasFocus(0)'): + self.setProperty('on.extras', '') + elif xbmc.getCondVisibility('ControlGroup(60).HasFocus(0)'): + self.setProperty('on.extras', '1') + + def searchButtonClicked(self): + self.processCommand(search.dialog(self, section_id=self.prev.getLibrarySectionId() or None)) + + def roleClicked(self): + mli = self.rolesListControl.getSelectedItem() + if not mli: + return + + sectionRoles = busy.widthDialog(mli.dataSource.sectionRoles, '') + + if not sectionRoles: + util.DEBUG_LOG('No sections found for actor') + return + + if len(sectionRoles) > 1: + x, y = self.getRoleItemDDPosition() + + options = [{'role': r, 'display': r.reasonTitle} for r in sectionRoles] + choice = dropdown.showDropdown(options, (x, y), pos_is_bottom=True, close_direction='bottom') + + if not choice: + return + + role = choice['role'] + else: + role = sectionRoles[0] + + self.processCommand(opener.open(role)) + + def getRoleItemDDPosition(self): + y = 1000 + if xbmc.getCondVisibility('Control.IsVisible(500)'): + y += 360 + if xbmc.getCondVisibility('Control.IsVisible(501)'): + y += 520 + if xbmc.getCondVisibility('!String.IsEmpty(Window.Property(on.extras))'): + y -= 300 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),0) + Control.IsVisible(500)'): + y -= 500 + if xbmc.getCondVisibility('Integer.IsGreater(Window.Property(hub.focus),1) + Control.IsVisible(501)'): + y -= 500 + + focus = int(xbmc.getInfoLabel('Container(403).Position')) + + x = ((focus + 1) * 304) - 100 + return x, y + + def setBackground(self): + video = self.video if self.video else self.playQueue.current() + self.windowSetBackground(video.defaultArt.asTranscodedImageURL(1920, 1080, opacity=60, + background=colors.noAlpha.Background)) + + def changeBackground(self, url, **kwargs): + self.windowSetBackground(url) + + def sessionEnded(self, session_id=None, **kwargs): + if session_id != id(self): + util.DEBUG_LOG('VideoPlayerWindow: Ignoring session end (ID: {0} - SessionID: {1})'.format(id(self), session_id)) + return + + util.DEBUG_LOG('VideoPlayerWindow: Session ended - closing (ID: {0})'.format(id(self))) + self.doClose() + + def play(self, resume=False, handler=None): + self.hidePostPlay() + + self.setBackground() + if self.playQueue: + player.PLAYER.playVideoPlaylist(self.playQueue, resume=self.resume, session_id=id(self), handler=handler) + elif self.video: + player.PLAYER.playVideo(self.video, resume=self.resume, force_update=True, session_id=id(self), handler=handler) + + def openItem(self, control=None, item=None): + if not item: + mli = control.getSelectedItem() + if not mli: + return + item = mli.dataSource + + self.processCommand(opener.open(item)) + + def showPostPlay(self): + self.postPlayMode = True + self.setProperty('post.play', '1') + + def hidePostPlay(self): + self.postPlayMode = False + self.setProperty('post.play', '') + self.setProperties(( + 'post.play.background', + 'info.title', + 'info.duration', + 'info.summary', + 'info.date', + 'next.thumb', + 'next.title', + 'next.subtitle', + 'prev.thumb', + 'prev.title', + 'prev.subtitle', + 'related.header', + 'has.next' + ), '') + + self.onDeckListControl.reset() + self.relatedListControl.reset() + self.rolesListControl.reset() + + @busy.dialog() + def postPlay(self, video=None, playlist=None, handler=None, stoppedInBingeMode=False, **kwargs): + util.DEBUG_LOG('VideoPlayer: Starting post-play') + self.showPostPlay() + self.prev = video + self.playlist = playlist + self.handler = handler + + self.getHubs() + + self.setProperty( + 'thumb.fallback', 'script.plex/thumb_fallbacks/{0}.png'.format(self.prev.type in ('show', 'season', 'episode') and 'show' or 'movie') + ) + + util.DEBUG_LOG('PostPlay: Showing video info') + if self.next: + self.next.reload(includeExtras=1, includeExtrasCount=10) + + self.relatedPaginator = RelatedPaginator(self.relatedListControl, + leaf_count=int((self.prev or self.next).relatedCount), + parent_window=self) + + vid = self.prev or self.next + if vid.sectionOnDeckCount: + self.onDeckPaginator = OnDeckPaginator(self.onDeckListControl, + leaf_count=int(vid.sectionOnDeckCount), + parent_window=self) + + self.setInfo() + self.fillOnDeck() + hasPrev = self.fillRelated() + self.fillRoles(hasPrev) + + if not stoppedInBingeMode: + self.startTimer() + + if self.next: + self.setFocusId(self.NEXT_BUTTON_ID) + else: + self.setFocusId(self.PREV_BUTTON_ID) + self.postPlayInitialized = True + + def resetPassoutProtection(self): + self.passoutProtection = time.time() + PASSOUT_PROTECTION_DURATION_SECONDS + + def startTimer(self): + if not util.getUserSetting('post_play_auto', True): + util.DEBUG_LOG('Post play auto-play disabled') + return + + if not self.next: + return + + if time.time() > self.passoutProtection and self.prev.duration.asInt() > PASSOUT_LAST_VIDEO_DURATION_MILLIS: + util.DEBUG_LOG('Post play auto-play skipped: Passout protection') + return + else: + millis = (self.passoutProtection - time.time()) * 1000 + util.DEBUG_LOG('Post play auto-play: Passout protection in {0}'.format(util.durationToShortText(millis))) + + self.timeout = time.time() + abs(util.advancedSettings.postplayTimeout) + util.DEBUG_LOG('Starting post-play timer until: %i' % self.timeout) + threading.Thread(target=self.countdown).start() + + def cancelTimer(self): + if self.timeout is not None: + util.DEBUG_LOG('Canceling post-play timer') + + self.timeout = None + self.setProperty('countdown', '') + + def countdown(self): + while self.timeout and not util.MONITOR.waitForAbort(0.1): + now = time.time() + if self.timeout and now > self.timeout: + self.timeout = None + self.setProperty('countdown', '') + util.DEBUG_LOG('Post-play timer finished') + # This works. The direct method caused the OSD to be broken, possibly because it was triggered from another thread? + # That was the only real difference I could see between the direct method and the user actually clicking the button. + xbmc.executebuiltin('SendClick(,{0})'.format(self.NEXT_BUTTON_ID)) + # Direct method, causes issues with OSD + # self.playVideo() + break + elif self.timeout is not None: + cd = min(abs(util.advancedSettings.postplayTimeout-1), int((self.timeout or now) - now)) + base = 15 / float(util.advancedSettings.postplayTimeout-1) + self.setProperty('countdown', str(int(math.ceil(base*cd)))) + + def getHubs(self): + try: + self.hubs = self.prev.postPlay() + except: + util.ERROR("No data - disconnected?", notify=True, time_ms=5000) + self.doClose() + return + + self.next = None + + if self.playlist: + if self.prev != self.playlist.current(): + self.next = self.playlist.current() + else: + if self.prev.type == 'episode' and 'tv.upnext' in self.hubs: + self.next = self.hubs['tv.upnext'].items[-1] + + if self.next: + self.setProperty('has.next', '1') + + def setInfo(self): + if self.next: + self.setProperty( + 'post.play.background', + util.backgroundFromArt(self.next.art, width=self.width, height=self.height) + ) + self.setProperty('info.title', self.next.title) + self.setProperty('info.duration', util.durationToText(self.next.duration.asInt())) + self.setProperty('info.summary', self.next.summary) + + if self.prev: + self.setProperty( + 'post.play.background', + util.backgroundFromArt(self.prev.art, width=self.width, height=self.height) + ) + self.setProperty('prev.info.title', self.prev.title) + self.setProperty('prev.info.duration', util.durationToText(self.prev.duration.asInt())) + self.setProperty('prev.info.summary', self.prev.summary) + + if self.prev.type == 'episode': + self.setProperty('related.header', T(32306, 'Related Shows')) + if self.next: + self.setProperty('next.thumb', self.next.thumb.asTranscodedImageURL(*self.NEXT_DIM)) + self.setProperty('info.date', util.cleanLeadingZeros(self.next.originallyAvailableAt.asDatetime('%B %d, %Y'))) + + self.setProperty('next.title', self.next.grandparentTitle) + self.setProperty( + 'next.subtitle', u'{0} {1} \u2022 {2} {3}'.format(T(32303, 'Season'), self.next.parentIndex, T(32304, 'Episode'), self.next.index) + ) + if self.prev: + self.setProperty('prev.thumb', self.prev.thumb.asTranscodedImageURL(*self.PREV_DIM)) + self.setProperty('prev.title', self.prev.grandparentTitle) + self.setProperty( + 'prev.subtitle', u'{0} {1} \u2022 {2} {3}'.format(T(32303, 'Season'), self.prev.parentIndex, T(32304, 'Episode'), self.prev.index) + ) + self.setProperty('prev.info.date', util.cleanLeadingZeros(self.prev.originallyAvailableAt.asDatetime('%B %d, %Y'))) + elif self.prev.type == 'movie': + self.setProperty('related.header', T(32404, 'Related Movies')) + if self.next: + self.setProperty('next.thumb', self.next.defaultArt.asTranscodedImageURL(*self.NEXT_DIM)) + self.setProperty('info.date', self.next.year) + + self.setProperty('next.title', self.next.title) + self.setProperty('next.subtitle', self.next.year) + if self.prev: + self.setProperty('prev.thumb', self.prev.defaultArt.asTranscodedImageURL(*self.PREV_DIM)) + self.setProperty('prev.title', self.prev.title) + self.setProperty('prev.subtitle', self.prev.year) + self.setProperty('prev.info.date', self.prev.year) + + def fillOnDeck(self): + if not self.onDeckPaginator: + return False + + if not self.onDeckPaginator.leafCount: + self.onDeckPaginator.reset() + return False + + items = self.onDeckPaginator.paginate() + + if not items: + return False + + return True + + def fillRelated(self, has_prev=False): + if not self.relatedPaginator.leafCount: + self.relatedListControl.reset() + return False + + items = self.relatedPaginator.paginate() + + if not items: + return False + + self.setProperty('divider.{0}'.format(self.RELATED_LIST_ID), has_prev and '1' or '') + return True + + def fillRoles(self, has_prev=False): + items = [] + idx = 0 + + video = self.next if self.next else self.prev + + if not video.roles: + self.rolesListControl.reset() + return False + + for role in video.roles(): + mli = kodigui.ManagedListItem(role.tag, role.role, thumbnailImage=role.thumb.asTranscodedImageURL(*self.ROLES_DIM), data_source=role) + mli.setProperty('index', str(idx)) + items.append(mli) + idx += 1 + + if not items: + return False + + self.setProperty('divider.{0}'.format(self.ROLES_LIST_ID), has_prev and '1' or '') + + self.rolesListControl.reset() + self.rolesListControl.addItems(items) + return True + + def playVideo(self, prev=False): + self.cancelTimer() + try: + if not self.next and self.playlist: + if prev: + self.playlist.prev() + self.aborted = False + self.playQueue = self.playlist + self.video = None + self.play(handler=self.handler) + else: + video = self.next + if prev: + video = self.prev + + if not video: + util.DEBUG_LOG('Trying to play next video with no next video available') + self.video = None + return + + self.playQueue = None + self.video = video + self.play(handler=self.handler) + except: + util.ERROR() + + +def play(video=None, play_queue=None, resume=False): + try: + w = VideoPlayerWindow.open(video=video, play_queue=play_queue, resume=resume) + except util.NoDataException: + raise + finally: + player.PLAYER.off('session.ended', w.sessionEnded) + player.PLAYER.off('post.play', w.postPlay) + player.PLAYER.off('av.started', w.playerPlaybackStarted) + player.PLAYER.off('change.background', w.changeBackground) + player.PLAYER.reset() + command = w.exitCommand + del w + util.garbageCollect() + return command diff --git a/script.plexmod/lib/windows/windowutils.py b/script.plexmod/lib/windows/windowutils.py new file mode 100644 index 000000000..9914d8c87 --- /dev/null +++ b/script.plexmod/lib/windows/windowutils.py @@ -0,0 +1,84 @@ +from __future__ import absolute_import +from lib import util +from . import opener +from . import dropdown + +from lib.util import T + + +HOME = None + + +class UtilMixin(): + def __init__(self): + self.exitCommand = None + + def goHome(self, section=None): + HOME.show() + if section: + self.closeWithCommand('HOME:{0}'.format(section)) + else: + self.closeWithCommand('HOME') + + def openItem(self, obj): + self.processCommand(opener.open(obj)) + + def openWindow(self, window_class, **kwargs): + self.processCommand(opener.handleOpen(window_class, **kwargs)) + + def processCommand(self, command): + if command and command.startswith('HOME'): + self.exitCommand = command + self.doClose() + elif command and command == "NODATA": + raise util.NoDataException + + def closeWithCommand(self, command): + self.exitCommand = command + self.doClose() + + def showAudioPlayer(self, **kwargs): + from . import musicplayer + self.processCommand(opener.handleOpen(musicplayer.MusicPlayerWindow, **kwargs)) + + def getPlaylistResume(self, pl, items, title): + resume = False + watched = False + for i in items: + if (watched and not i.isWatched) or i.get('viewOffset').asInt(): + if i.get('viewOffset'): + choice = dropdown.showDropdown( + options=[ + {'key': 'resume', 'display': T(32429, 'Resume from {0}').format(util.timeDisplay(i.viewOffset.asInt()).lstrip('0').lstrip(':'))}, + {'key': 'play', 'display': T(32317, 'Play from beginning')} + ], + pos=(660, 441), + close_direction='none', + set_dropdown_prop=False, + header=u'{0} - {1}{2} \u2022 {3}{4}'.format(title, T(32310, 'S'), i.parentIndex, T(32311, 'E'), i.index) + ) + + if not choice: + return None + + if choice['key'] == 'resume': + resume = True + + pl.setCurrent(i) + break + elif i.isWatched: + watched = True + else: + break + + return resume + + +def shutdownHome(): + global HOME + if HOME: + HOME._shuttingDown = True + HOME.shutdown() + del HOME + HOME = None + HOME diff --git a/script.plexmod/plugin.py b/script.plexmod/plugin.py new file mode 100644 index 000000000..6e2eec66e --- /dev/null +++ b/script.plexmod/plugin.py @@ -0,0 +1,75 @@ +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcplugin +from kodi_six import xbmcgui +import sys +import base64 +from lib import _included_packages, plex, util +from plexnet import audio, plexplayer, plexapp +from plexnet import util as plexnetUtil + +HANDLE = int(sys.argv[1]) + +BASE_LOG = util.LOG + + +def LOG(msg): + BASE_LOG('(plugin) - {0}'.format(plexnetUtil.cleanToken(msg))) + + +util.LOG = LOG + + +def playTrack(track): + track.reload() + apobj = plexplayer.PlexAudioPlayer(track) + url = apobj.build()['url'] + url = util.addURLParams(url, { + 'X-Plex-Client-Profile-Name': 'Generic', + 'X-Plex-Client-Identifier': plexapp.util.INTERFACE.getGlobal('clientIdentifier') + }) + LOG('Playing URL: {0}'.format(url)) + + return xbmcgui.ListItem(path=url) + + +def playVideo(video): + return None + + +def play(data): + try: + from plexnet import plexobjects + + plexObject = plexobjects.PlexObject.deSerialize(base64.urlsafe_b64decode(data.encode('utf-8'))) + + if plexObject.type == 'track': + listitem = playTrack(plexObject) + elif plexObject.type in ('episode', 'movie', 'clip'): + listitem = playVideo(plexObject) + except: + util.ERROR() + xbmcplugin.setResolvedUrl(HANDLE, False, None) + return + + xbmcplugin.setResolvedUrl(HANDLE, True, listitem) + + +def main(): + try: + if len(sys.argv) < 3: + return + + path = sys.argv[0].split('/', 3)[-1] + data = sys.argv[2].lstrip('?') + + if path == 'play': + play(data) + else: # This is a hack since it's both a plugin and a script. My Addons and Shortcuts otherwise can't launch the add-on + xbmc.executebuiltin('Action(back)') # This sometimes works to back out of the plugin directory display + xbmc.executebuiltin('RunScript(script.plexmod)') + except: + util.ERROR() + + +main() diff --git a/script.plexmod/pm4k_cache_template.xml b/script.plexmod/pm4k_cache_template.xml new file mode 100644 index 000000000..82ad94e32 --- /dev/null +++ b/script.plexmod/pm4k_cache_template.xml @@ -0,0 +1,5 @@ + + 1 + {memorysize} + {readfactor} + \ No newline at end of file diff --git a/script.plexmod/resources/language/resource.language.cs_cz/strings.po b/script.plexmod/resources/language/resource.language.cs_cz/strings.po new file mode 100644 index 000000000..189e635a4 --- /dev/null +++ b/script.plexmod/resources/language/resource.language.cs_cz/strings.po @@ -0,0 +1,927 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: 2017-02-04 14:36+0100\n" +"Language-Team: LANGUAGE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: cs\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Last-Translator: Michal Kuncl \n" +"X-Generator: Poedit 2.0beta2\n" + +msgctxt "#32000" +msgid "Main" +msgstr "Hlavní" + +msgctxt "#32001" +msgid "Original" +msgstr "Originální" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Mb/s 1080p" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Mb/s 1080p" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Mb/s 1080p" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Mb/s 1080p" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Mb/s 720p" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Mb/s 720p" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Mb/s 720p" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Mb/s 480p" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 kb/s" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 kb/s" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 kb/s" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 kb/s" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 kb/s" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "Místní kvalita" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Vzdálená kvalita" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "Online kvalita" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Formát překódování" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Ladící záznamy" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Povolit přímé přehrávání" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Povolit přímé streamování" + +msgctxt "#32027" +msgid "Force" +msgstr "Vynutit" + +msgctxt "#32028" +msgid "Always" +msgstr "Vždy" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "Pouze obrázkové formáty" + +msgctxt "#32030" +msgid "Auto" +msgstr "Automaticky" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "Vpálit titulky (pouze přímá přehrávání)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Povolit nezabezpečené připojení" + +msgctxt "#32033" +msgid "Never" +msgstr "Nikdy" + +msgctxt "#32034" +msgid "On Same network" +msgstr "Pouze na stejné síti" + +msgctxt "#32035" +msgid "Always" +msgstr "Vždy" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "Povolit 4K" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "Povolit HEVC (H265)" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Automaticky přihlašovat" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Automatické přehrání po skončení" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Povolit stahování titulků" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Povolit stahování titulků" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Objevení serveru (GDM)" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Spustit plex při spuštění Kodi" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "IP připojení 1" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "Port připojení 1" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "IP připojení 2" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "Port připojení 2" + +msgctxt "#32048" +msgid "Audio" +msgstr "Audio" + +msgctxt "#32049" +msgid "Advanced" +msgstr "Rozšířené" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Ruční servery" + +msgctxt "#32051" +msgid "Privacy" +msgstr "Soukromí" + +msgctxt "#32052" +msgid "About" +msgstr "O doplňku" + +msgctxt "#32053" +msgid "Video" +msgstr "Video" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "Verze doplňku" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Verze Kodi" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Rozlišení obrazovky" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Verze serveru" + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "Při spuštění přeskočit výběr uživatele a zadávání pinu." + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Pokud povolíte, po zkončení přehrávání se automaticky přehraje položka ze seznamu 'Následující' po prodlevě 15 sekund." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Povolte pokud váš hardware zvládne přehrávat 4K videa. Zakázáním vynutíte překódování." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Povolte pokud váš hardware zvládne přehrávat formát HEVC/H265. Zakázáním vynutíte překódování." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Kdy se připojovat k serverům přes nezabezpečené připojení.[CR][CR]* [B]Nikdy[/B]: Nepoužívat nezabezpečená připojení[CR]* [B]Na stejné síti[/B]: Povolte pokud jste na stejné síti jako server[CR]* [B]Vždy[/B]: Povolí na stejné síti a při vzdálených připojeních" + +msgctxt "#32201" +msgid "Trailer" +msgstr "Ukázka" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Smazaná scéna" + +msgctxt "#32203" +msgid "Interview" +msgstr "Rozhovor" + +msgctxt "#32204" +msgid "Music Video" +msgstr "Videoklip" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "Ze zákulisí" + +msgctxt "#32206" +msgid "Scene" +msgstr "Scéna" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Živý videoklip" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Videoklip s textem" + +msgctxt "#32209" +msgid "Concert" +msgstr "Koncert" + +msgctxt "#32210" +msgid "Featurette" +msgstr "" + +msgctxt "#32211" +msgid "Short" +msgstr "Krátký film" + +msgctxt "#32212" +msgid "Other" +msgstr "Jiný" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "Přejít na album" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Přejít na interpreta" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Přejít na {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "Sezóna" + +msgctxt "#32304" +msgid "Episode" +msgstr "Epizoda" + +msgctxt "#32305" +msgid "Extras" +msgstr "Extra" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "Podobné seriály" + +msgctxt "#32307" +msgid "More" +msgstr "Více" + +msgctxt "#32308" +msgid "Available" +msgstr "K dispozici" + +msgctxt "#32309" +msgid "None" +msgstr "Žádné" + +msgctxt "#32310" +msgid "S" +msgstr "" + +msgctxt "#32311" +msgid "E" +msgstr "" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "Není k dispozici" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Tato položka momentálně není k dispozici" + +msgctxt "#32314" +msgid "In Progress" +msgstr "Rozkoukané" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "Pokračovat v přehrávání?" + +msgctxt "#32316" +msgid "Resume" +msgstr "Pokračovat" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Přehrát od začátku" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Označit jako neshlédnuté" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "Označit jako shlédnuté" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Označit sezónu jako neshlédnutou" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Označit sezónu jako shlédnutou" + +msgctxt "#32322" +msgid "Delete" +msgstr "Smazat" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "Přejít na seriál" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Přejít na {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "Přehrát další" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "Opravdu smazat?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "Opravdu chcete smazat tyto média?" + +msgctxt "#32328" +msgid "Yes" +msgstr "Ano" + +msgctxt "#32329" +msgid "No" +msgstr "Ne" + +msgctxt "#32330" +msgid "Message" +msgstr "Zpráva" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "Při mazání média se vyskytla chyba." + +msgctxt "#32332" +msgid "Home" +msgstr "Domů" + +msgctxt "#32333" +msgid "Playlists" +msgstr "Playlisty" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Potvrdit odchod" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "Opravdu chcete odejít z Plexu?" + +msgctxt "#32336" +msgid "Exit" +msgstr "Odejít" + +msgctxt "#32337" +msgid "Cancel" +msgstr "Zrušit" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Nenalezeny žádné servery" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "Server je nedostupný" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "Probíhají zkoušky připojení. Prosím počkejte." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "Server je nedostupný. Prosím přihlašte se ke svému serveru a zkontrolujte připojení." + +msgctxt "#32342" +msgid "Switch User" +msgstr "Přepnout uživatele" + +msgctxt "#32343" +msgid "Settings" +msgstr "Nastavení" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "Odhlásit" + +msgctxt "#32345" +msgid "All" +msgstr "Všechno" + +msgctxt "#32346" +msgid "By Name" +msgstr "Podle názvu" + +msgctxt "#32347" +msgid "artists" +msgstr "interpreti" + +msgctxt "#32348" +msgid "movies" +msgstr "filmy" + +msgctxt "#32349" +msgid "photos" +msgstr "fotky" + +msgctxt "#32350" +msgid "shows" +msgstr "seriály" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "Podle data přidání" + +msgctxt "#32352" +msgid "Date Added" +msgstr "Datum přidání" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "Podle data vydání" + +msgctxt "#32354" +msgid "Release Date" +msgstr "Datum vydání" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "Podle data shlédnutí" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Datum shlédnutí" + +msgctxt "#32357" +msgid "By Name" +msgstr "Podle názvu" + +msgctxt "#32358" +msgid "Name" +msgstr "Název" + +msgctxt "#32359" +msgid "By Rating" +msgstr "Podle hodnocení" + +msgctxt "#32360" +msgid "Rating" +msgstr "Hodnocení" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "Podle rozlišení" + +msgctxt "#32362" +msgid "Resolution" +msgstr "Rozlišení" + +msgctxt "#32363" +msgid "By Duration" +msgstr "Podle délky" + +msgctxt "#32364" +msgid "Duration" +msgstr "Délka" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "Podle prvního vysílání" + +msgctxt "#32366" +msgid "First Aired" +msgstr "První vysílání" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "Podle neshlédnutí" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "Neshlédnuté" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "Podle data přehrání" + +msgctxt "#32370" +msgid "Date Played" +msgstr "Datum přehrání" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "Podle počtu přehrání" + +msgctxt "#32372" +msgid "Play Count" +msgstr "Počet přehrání" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "Podle data pořízení" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "Datum pořízení" + +msgctxt "#32375" +msgid "No filters available" +msgstr "Filtry nejsou k dispozici" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Odstranit filtr" + +msgctxt "#32377" +msgid "Year" +msgstr "Rok" + +msgctxt "#32378" +msgid "Decade" +msgstr "Desetiletí" + +msgctxt "#32379" +msgid "Genre" +msgstr "Žánr" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "Hodnocení obsahu" + +msgctxt "#32381" +msgid "Network" +msgstr "Stanice" + +msgctxt "#32382" +msgid "Collection" +msgstr "Kolekce" + +msgctxt "#32383" +msgid "Director" +msgstr "Režie" + +msgctxt "#32384" +msgid "Actor" +msgstr "Herci" + +msgctxt "#32385" +msgid "Country" +msgstr "Země" + +msgctxt "#32386" +msgid "Studio" +msgstr "Studio" + +msgctxt "#32387" +msgid "Labels" +msgstr "Popisky" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "Výrobce kamery" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "Model kamery" + +msgctxt "#32390" +msgid "Aperture" +msgstr "Clona" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Rychlost závěrky" + +msgctxt "#32392" +msgid "Lens" +msgstr "Čočka" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "Seriály" + +msgctxt "#32394" +msgid "Music" +msgstr "Hudba" + +msgctxt "#32395" +msgid "Audio" +msgstr "Zvuková stopa" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "Titulky" + +msgctxt "#32397" +msgid "Quality" +msgstr "Kvalita" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Video nastavení Kodi" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Audio nastavení Kodi" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "Přejít na sezónu" + +msgctxt "#32401" +msgid "Directors" +msgstr "Režiséři" + +msgctxt "#32402" +msgid "Writer" +msgstr "Autor" + +msgctxt "#32403" +msgid "Writers" +msgstr "Autoři" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "Podobné filmy" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Stáhnout titulky" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Zpoždění titulků" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Další titulky" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Vypnout titulky" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Zapnout titulky" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "Verze platformy" + +msgctxt "#32411" +msgid "Unknown" +msgstr "Neznámá" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Upravit nebo smazat" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "Upravit IP adresu nebo smazat nastavení?" + +msgctxt "#32414" +msgid "Clear" +msgstr "Smazat" + +msgctxt "#32415" +msgid "Edit" +msgstr "Upravit" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "Zadejte IP adresu" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Zadejte číslo portu" + +msgctxt "#32418" +msgid "Creator" +msgstr "Tvůrce" + +msgctxt "#32419" +msgid "Cast" +msgstr "Hrají" + +msgctxt "#32420" +msgid "Disc" +msgstr "Disk" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "Odhlásit" + +msgctxt "#32422" +msgid "Exit" +msgstr "Odejít" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "Vypnout" + +msgctxt "#32424" +msgid "Suspend" +msgstr "Uspat" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "Hibernovat" + +msgctxt "#32426" +msgid "Reboot" +msgstr "Restartovat" + +msgctxt "#32427" +msgid "Failed" +msgstr "Neuspělo" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "Přihlášení neuspělo!" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Pokračovat od {0}" + +msgctxt "#32430" +msgid "Discovery" +msgstr "Objevit" + +msgctxt "#32431" +msgid "Search" +msgstr "Vyhledat" + +msgctxt "#32432" +msgid "Space" +msgstr "Mezera" + +msgctxt "#32433" +msgid "Clear" +msgstr "Smazat" + +msgctxt "#32434" +msgid "Searching..." +msgstr "Vyhledávám..." + +msgctxt "#32435" +msgid "No Results" +msgstr "Žádné výsledky" + +msgctxt "#32436" +msgid "Paused" +msgstr "Pozastaveno" + +msgctxt "#32437" +msgid "Welcome" +msgstr "Vítáme vás" + +msgctxt "#32438" +msgid "Previous" +msgstr "Předchozí" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "Přehrávám další" + +msgctxt "#32440" +msgid "On Deck" +msgstr "Aktuální" + +msgctxt "#32441" +msgid "Unknown" +msgstr "Neznámý" + +msgctxt "#32442" +msgid "Embedded" +msgstr "Vestavěné" + +msgctxt "#32443" +msgid "Forced" +msgstr "Vynucené" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "Text skladby" + +msgctxt "#32445" +msgid "Mono" +msgstr "Mono" + +msgctxt "#32446" +msgid "Stereo" +msgstr "Stereo" + +msgctxt "#32447" +msgid "None" +msgstr "Žádné" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "Přehrávání neuspělo!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "Nemohu se připojit k plex.tv[CR]Zkontrolujte připojení k internetu a zkuste to znovu." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "Zvolte verzi" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "Přehrávám verzi..." + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "V této knihovně není žádný obsah" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "Prosím přidejte obsah a nebo zkontrolujte, že je povolené nastavení 'přidat na nástěnku'." + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Pro tento filtr není k dispozici žádný obsah" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Prosím změňte nebo odstraňte filtr" + +msgctxt "#32456" +msgid "Offline Mode" +msgstr "Režim offline" + +msgctxt "#32457" +msgid "Sign In" +msgstr "Přihlásit" diff --git a/script.plexmod/resources/language/resource.language.de_de/strings.po b/script.plexmod/resources/language/resource.language.de_de/strings.po new file mode 100644 index 000000000..620c5f41b --- /dev/null +++ b/script.plexmod/resources/language/resource.language.de_de/strings.po @@ -0,0 +1,1938 @@ +msgid "" +msgstr "" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: PM4K\n" +"Language: de\n" + +#: +msgctxt "#32000" +msgid "Main" +msgstr "Allgemein" + +#: +msgctxt "#32001" +msgid "Original" +msgstr "Original" + +#: +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Mbit/s 1080p" + +#: +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Mbit/s 1080p" + +#: +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Mbit/s 1080p" + +#: +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Mbit/s 1080p" + +#: +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Mbit/s 720p" + +#: +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Mbit/s 720p" + +#: +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Mbit/s 720p" + +#: +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Mbit/s 480p" + +#: +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 kbit/s" + +#: +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 kbit/s" + +#: +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 kbit/s" + +#: +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 kbit/s" + +#: +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 kbit/s" + +#: +msgctxt "#32020" +msgid "Local Quality" +msgstr "Qualität für lokale Geräte" + +#: +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Qualität für entfernte Geräte" + +#: +msgctxt "#32022" +msgid "Online Quality" +msgstr "Online Qualität" + +#: +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Transkodierungsformat" + +#: +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Debug-Protokollierung" + +#: +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Direkte Wiedergabe erlauben" + +#: +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Direktes streamen erlauben" + +#: +msgctxt "#32027" +msgid "Force" +msgstr "Erzwingen" + +#: +msgctxt "#32028" +msgid "Always" +msgstr "Immer" + +#: +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "nur Bildformate" + +#: +msgctxt "#32030" +msgid "Auto" +msgstr "Automatisch" + +#: +msgctxt "#32031" +msgid "Burn-in Subtitles" +msgstr "Untertitel einbrennen" + +#: +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Unsichere Verbindung erlauben" + +#: +msgctxt "#32033" +msgid "Never" +msgstr "Nie" + +#: +msgctxt "#32034" +msgid "On Same network" +msgstr "Im selben Netzwerk" + +#: +msgctxt "#32035" +msgid "Always" +msgstr "Immer" + +#: +msgctxt "#32036" +msgid "Allow 4K" +msgstr "4K erlauben" + +#: +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "HEVC erlauben (H265)" + +#: +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Automatisch anmelden" + +#: +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Automatisch nächsten Titel abspielen" + +#: +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Untertitel download aktivieren" + +#: +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Untertitel download aktivieren" + +#: +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Server Erkennung (GDM)" + +#: +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Plex mit Kodi starten" + +#: +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "Verbindung 1 IP" + +#: +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "Verbindung 1 Port" + +#: +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "Verbindung 2 IP" + +#: +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "Verbindung 2 Port" + +#: +msgctxt "#32048" +msgid "Audio" +msgstr "Ton" + +#: +msgctxt "#32049" +msgid "Advanced" +msgstr "Erweitert" + +#: +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Manuelle Server" + +#: +msgctxt "#32051" +msgid "Privacy" +msgstr "Datenschutz" + +#: +msgctxt "#32052" +msgid "About" +msgstr "Über" + +#: +msgctxt "#32053" +msgid "Video" +msgstr "Video" + +#: +msgctxt "#32054" +msgid "Addon Version" +msgstr "Erweiterung Version" + +#: +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Kodi Version" + +#: +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Bilschirmauflösung" + +#: +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Aktuelle Server Version" + +#: +msgctxt "#32058" +msgid "Never exceed original audio codec" +msgstr "Niemals den ursprünglichen Audio-Codec überschreiten" + +#: +msgctxt "#32059" +msgid "When transcoding audio, never exceed the original audio bitrate or channel count on the same codec." +msgstr "Bei der Transkodierung von Audio niemals die ursprüngliche Audio-Bitrate oder die Anzahl der Kanäle mit demselben Codec überschreiten." + +#: +msgctxt "#32060" +msgid "Use Kodi audio channels" +msgstr "Kodi-Audiokanäle verwenden" + +#: +msgctxt "#32061" +msgid "When transcoding audio, target the audio channels set in Kodi." +msgstr "Beim Transkodieren von Audio werden die in Kodi eingestellten Audiokanäle verwendet." + +#: +msgctxt "#32062" +msgid "Transcode audio to AC3" +msgstr "Transkodiere Ton zu AC3" + +#: +msgctxt "#32063" +msgid "Transcode audio to AC3 in certain conditions (useful for passthrough)." +msgstr "Transkodiere Ton zu AC3 in bestimmten Umständen (nützlich für Passthrough)" + +#: +msgctxt "#32064" +msgid "Treat DTS like AC3" +msgstr "DTS wie AC3 behandeln" + +#: +msgctxt "#32065" +msgid "When any of the force AC3 settings are enabled, treat DTS the same as AC3 (useful for Optical passthrough)" +msgstr "Wenn einer der AC3 Optionen aktiviert ist, wird DTS wie AC3 behandelt (nützlich für Optical Passthrough)" + + +msgctxt "#32066" +msgid "Force audio to AC3" +msgstr "Audio immer als AC3 erzwingen" + + +msgctxt "#32067" +msgid "Only force multichannel audio to AC3" +msgstr "Nur Mehrkanal-Audio als AC3 erzwingen" + +#: +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "Benutzerauswahl und Pin-Eingabe beim Start überspringen." + +#: +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Falls aktiviert wird, sofern verfügbar, nachdem die Wiedergabe endet ein 'nächster Titel' automatisch nach 15 Sekunden gestartet." + +#: +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Aktiviert 4K Wiedergabe per Hardware. Deaktivierung erzwingt Transcodierung." + +#: +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Aktiviert HEVC/H265 Wiedergabe per Hardware. Deaktivierung erzwingt Transcodierung." + +#: +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Wann soll zu Servern ohne 'sichere Verbindung' verbunden werden[CR]* [B]Nie[/B]: Niemals unsicher zu eine Server verbinden.[CR]* [B]Im selben Netzwerk[/B]: Im selben Netzwerk erlauben[CR]* [B]Immer[/B]: Im selben Netzwerk und bei entfernter Verbindung erlauben" + +#: +msgctxt "#32201" +msgid "Trailer" +msgstr "Trailer" + +#: +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Entfernte Szene" + +#: +msgctxt "#32203" +msgid "Interview" +msgstr "Interview" + +#: +msgctxt "#32204" +msgid "Music Video" +msgstr "Musik-Video" + +#: +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "Hinter den Kulissen" + +#: +msgctxt "#32206" +msgid "Scene" +msgstr "Szene" + +#: +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Live Musikvideo" + +#: +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Musikvideo mit Lyrics" + +#: +msgctxt "#32209" +msgid "Concert" +msgstr "Konzert" + +#: +msgctxt "#32210" +msgid "Featurette" +msgstr "Featurette" + +#: +msgctxt "#32211" +msgid "Short" +msgstr "Kurzfilm" + +#: +msgctxt "#32212" +msgid "Other" +msgstr "Sonstige" + +#: +msgctxt "#32300" +msgid "Go to Album" +msgstr "Gehe zu Album" + +#: +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Gehe zu Künstler(in)" + +#: +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Gehe zu {0}" + +#: +msgctxt "#32303" +msgid "Season" +msgstr "Staffel" + +#: +msgctxt "#32304" +msgid "Episode" +msgstr "Folge" + +#: +msgctxt "#32305" +msgid "Extras" +msgstr "Extras" + +#: +msgctxt "#32306" +msgid "Related Shows" +msgstr "Ähnliche Serien" + +#: +msgctxt "#32307" +msgid "More" +msgstr "Film" + +#: +msgctxt "#32308" +msgid "Available" +msgstr "Verfügbar" + +#: +msgctxt "#32309" +msgid "None" +msgstr "Ohne" + +#: +msgctxt "#32310" +msgid "S" +msgstr "S" + +#: +msgctxt "#32311" +msgid "E" +msgstr "E" + +#: +msgctxt "#32312" +msgid "Unavailable" +msgstr "Nicht verfügbar" + +#: +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Dieser Eintrag ist aktuell nicht verfügbar." + +#: +msgctxt "#32314" +msgid "In Progress" +msgstr "in Bearbeitung" + +#: +msgctxt "#32315" +msgid "Resume playback?" +msgstr "Wiedergabe fortsetzen?" + +#: +msgctxt "#32316" +msgid "Resume" +msgstr "Fortsetzen" + +#: +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Von vorne wiedergeben" + +#: +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Als ungesehen markieren" + +#: +msgctxt "#32319" +msgid "Mark Played" +msgstr "Als gesehen markieren" + +#: +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Staffel als ungesehen markieren" + +#: +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Staffel als gesehen markieren" + +#: +msgctxt "#32322" +msgid "Delete" +msgstr "Löschen" + +#: +msgctxt "#32323" +msgid "Go To Show" +msgstr "Gehe zu Serie" + +#: +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Gehe zu {0}" + +#: +msgctxt "#32325" +msgid "Play Next" +msgstr "Nächster Titel" + +#: +msgctxt "#32326" +msgid "Really Delete?" +msgstr "Wirklich löschen?" + +#: +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "Soll diese Mediandatei wirklich gelöscht werden?" + +#: +msgctxt "#32328" +msgid "Yes" +msgstr "Ja" + +#: +msgctxt "#32329" +msgid "No" +msgstr "Nein" + +#: +msgctxt "#32330" +msgid "Message" +msgstr "Nachricht" + +#: +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "Es gab einen Fehler beim Löschversuch dieser Mediendatei." + +#: +msgctxt "#32332" +msgid "Home" +msgstr "Start" + +#: +msgctxt "#32333" +msgid "Playlists" +msgstr "Wiedergabelisten" + +#: +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Beenden bestätigen" + +#: +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "Bereit um Plex zu beenden?" + +#: +msgctxt "#32336" +msgid "Exit" +msgstr "Beenden" + +#: +msgctxt "#32337" +msgid "Cancel" +msgstr "Abbrechen" + +#: +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Keine Server gefunden" + +#: +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "Server ist nicht erreichbar" + +#: +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "Verbindungstests laufen. Bitte warten." + +#: +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "Der Server ist nicht erreichbar. Bitte einloggen und Verbindung prüfen." + +#: +msgctxt "#32342" +msgid "Switch User" +msgstr "Benutzer wechseln" + +#: +msgctxt "#32343" +msgid "Settings" +msgstr "Einstellungen" + +#: +msgctxt "#32344" +msgid "Sign Out" +msgstr "Abmelden" + +#: +msgctxt "#32345" +msgid "All" +msgstr "Alle" + +#: +msgctxt "#32346" +msgid "By Name" +msgstr "Nach Name" + +#: +msgctxt "#32347" +msgid "Artists" +msgstr "Künstler" + +#: +msgctxt "#32348" +msgid "Movies" +msgstr "Filme" + +#: +msgctxt "#32349" +msgid "photos" +msgstr "Fotos" + +#: +msgctxt "#32350" +msgid "Shows" +msgstr "Serien" + +#: +msgctxt "#32351" +msgid "By Date Added" +msgstr "Nach 'Hinzugefügt am'" + +#: +msgctxt "#32352" +msgid "Date Added" +msgstr "Hinzugefügt am" + +#: +msgctxt "#32353" +msgid "By Release Date" +msgstr "Nach Erscheinungsdatum" + +#: +msgctxt "#32354" +msgid "Release Date" +msgstr "Erscheinungsdatum" + +#: +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "Nach 'Gesehen am'" + +#: +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Gesehen am" + +#: +msgctxt "#32357" +msgid "By Name" +msgstr "Nach Name" + +#: +msgctxt "#32358" +msgid "Name" +msgstr "Name" + +#: +msgctxt "#32359" +msgid "By Rating" +msgstr "Nach Bewertung" + +#: +msgctxt "#32360" +msgid "Rating" +msgstr "Bewertung" + +#: +msgctxt "#32361" +msgid "By Resolution" +msgstr "Nach Auflösung" + +#: +msgctxt "#32362" +msgid "Resolution" +msgstr "Auflösung" + +#: +msgctxt "#32363" +msgid "By Duration" +msgstr "Nach Dauer" + +#: +msgctxt "#32364" +msgid "Duration" +msgstr "Dauer" + +#: +msgctxt "#32365" +msgid "By First Aired" +msgstr "Nach Erstausstrahlung" + +#: +msgctxt "#32366" +msgid "First Aired" +msgstr "Erstausstrahlung" + +#: +msgctxt "#32367" +msgid "By Unplayed" +msgstr "Nach Ungesehen" + +#: +msgctxt "#32368" +msgid "Unplayed" +msgstr "Ungesehen" + +#: +msgctxt "#32369" +msgid "By Date Played" +msgstr "Nach 'Abgespielt am'" + +#: +msgctxt "#32370" +msgid "Date Played" +msgstr "Abgespielt am" + +#: +msgctxt "#32371" +msgid "By Play Count" +msgstr "Nach Wiedergabe-Zähler" + +#: +msgctxt "#32372" +msgid "Play Count" +msgstr "Wiedergabe-Zähler" + +#: +msgctxt "#32373" +msgid "By Date Taken" +msgstr "Nach Aufnahmedatum" + +#: +msgctxt "#32374" +msgid "Date Taken" +msgstr "Aufnahmedatum" + +#: +msgctxt "#32375" +msgid "No filters available" +msgstr "Keine Filter verfügbar" + +#: +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Filter bereinigen" + +#: +msgctxt "#32377" +msgid "Year" +msgstr "Jahr" + +#: +msgctxt "#32378" +msgid "Decade" +msgstr "Jahrzent" + +#: +msgctxt "#32379" +msgid "Genre" +msgstr "Genre" + +#: +msgctxt "#32380" +msgid "Content Rating" +msgstr "Jugendfreigabe" + +#: +msgctxt "#32381" +msgid "Network" +msgstr "Netzwerk" + +#: +msgctxt "#32382" +msgid "Collection" +msgstr "Sammlung" + +#: +msgctxt "#32383" +msgid "Director" +msgstr "Regisseur(in)" + +#: +msgctxt "#32384" +msgid "Actor" +msgstr "Schauspieler(in)" + +#: +msgctxt "#32385" +msgid "Country" +msgstr "Land" + +#: +msgctxt "#32386" +msgid "Studio" +msgstr "Studio" + +#: +msgctxt "#32387" +msgid "Labels" +msgstr "Labels" + +#: +msgctxt "#32388" +msgid "Camera Make" +msgstr "Kamerahersteller" + +#: +msgctxt "#32389" +msgid "Camera Model" +msgstr "Kameramodell" + +#: +msgctxt "#32390" +msgid "Aperture" +msgstr "Blendenöffnung" + +#: +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Verschlusszeit" + +#: +msgctxt "#32392" +msgid "Lens" +msgstr "Linse" + +#: +msgctxt "#32393" +msgid "TV Shows" +msgstr "Fernsehserien" + +#: +msgctxt "#32394" +msgid "Music" +msgstr "Musik" + +#: +msgctxt "#32395" +msgid "Audio" +msgstr "Ton" + +#: +msgctxt "#32396" +msgid "Subtitles" +msgstr "Untertitel" + +#: +msgctxt "#32397" +msgid "Quality" +msgstr "Qualität" + +#: +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Kodi Bild Einstellungen" + +#: +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Kodi Ton Einstellungen" + +#: +msgctxt "#32400" +msgid "Go To Season" +msgstr "Gehe zu Staffel" + +#: +msgctxt "#32401" +msgid "Directors" +msgstr "Regisseure" + +#: +msgctxt "#32402" +msgid "Writer" +msgstr "Autor(in)" + +#: +msgctxt "#32403" +msgid "Writers" +msgstr "Autoren" + +#: +msgctxt "#32404" +msgid "Related Movies" +msgstr "Ähnliche Filme" + +#: +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Untertitel downloaden" + +#: +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Untertitel Verzögerung" + +#: +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Nächster Untertitel" + +#: +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Untertitel deaktiviren" + +#: +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Untertitel aktivieren" + +#: +msgctxt "#32410" +msgid "Platform Version" +msgstr "Platform Version" + +#: +msgctxt "#32411" +msgid "Unknown" +msgstr "Unbekannt" + +#: +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Bearbeiten oder bereinigen" + +#: +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "IP Adresse bearbeiten oder die aktuellen Einstellungen bereinigen?" + +#: +msgctxt "#32414" +msgid "Clear" +msgstr "Bereinigen" + +#: +msgctxt "#32415" +msgid "Edit" +msgstr "Ändern" + +#: +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "IP Adresse eingeben" + +#: +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Port Nummer eingeben" + +#: +msgctxt "#32418" +msgid "Creator" +msgstr "Urheber(in)" + +#: +msgctxt "#32419" +msgid "Cast" +msgstr "Besetzung" + +#: +msgctxt "#32420" +msgid "Disc" +msgstr "Disk" + +#: +msgctxt "#32421" +msgid "Sign Out" +msgstr "Abmelden" + +#: +msgctxt "#32422" +msgid "Exit" +msgstr "Beenden" + +#: +msgctxt "#32423" +msgid "Shutdown" +msgstr "Herunterfahren" + +#: +msgctxt "#32424" +msgid "Suspend" +msgstr "Energie sparen" + +#: +msgctxt "#32425" +msgid "Hibernate" +msgstr "Ruhezustand" + +#: +msgctxt "#32426" +msgid "Reboot" +msgstr "Neustart" + +#: +msgctxt "#32427" +msgid "Failed" +msgstr "Fehlgeschlagen" + +#: +msgctxt "#32428" +msgid "Login failed!" +msgstr "Anmeldung fehlgeschlagen!" + +#: +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Fortsetzen ab {0}" + +#: +msgctxt "#32430" +msgid "Discovery" +msgstr "Entdecken" + +#: +msgctxt "#32431" +msgid "Search" +msgstr "Suche" + +#: +msgctxt "#32432" +msgid "Space" +msgstr "Leerzeichen" + +#: +msgctxt "#32433" +msgid "Clear" +msgstr "Bereinigen" + +#: +msgctxt "#32434" +msgid "Searching..." +msgstr "Suche..." + +#: +msgctxt "#32435" +msgid "No Results" +msgstr "Keine Ergebnisse" + +#: +msgctxt "#32436" +msgid "Paused" +msgstr "Pausiert" + +#: +msgctxt "#32437" +msgid "Welcome" +msgstr "Willkommen" + +#: +msgctxt "#32438" +msgid "Previous" +msgstr "Vorheriger Titel" + +#: +msgctxt "#32439" +msgid "Playing Next" +msgstr "Nächster Titel" + +#: +msgctxt "#32440" +msgid "On Deck" +msgstr "Aktuell" + +#: +msgctxt "#32441" +msgid "Unknown" +msgstr "Unbekannt" + +#: +msgctxt "#32442" +msgid "Embedded" +msgstr "Eingebettet" + +#: +msgctxt "#32443" +msgid "Forced" +msgstr "Zwangsuntertitel" + +#: +msgctxt "#32444" +msgid "Lyrics" +msgstr "Lyrics" + +#: +msgctxt "#32445" +msgid "Mono" +msgstr "Mono" + +#: +msgctxt "#32446" +msgid "Stereo" +msgstr "Stereo" + +#: +msgctxt "#32447" +msgid "None" +msgstr "Nichts" + +#: +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "Wiedergabe fehlgeschlagen!" + +#: +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "Keine Verbindung zu plex.tv[CR]Internetverbindung prüfen und erneut versuchen." + +#: +msgctxt "#32450" +msgid "Choose Version" +msgstr "Version auswählen" + +#: +msgctxt "#32451" +msgid "Play Version..." +msgstr "Version abspielen..." + +#: +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "In dieser Bibliothek ist nichts vorhanden" + +#: +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "Bitte Inhalte ergänzen und/oder prüfen ob 'In Dashboard anzeigen' aktiv ist." + +#: +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Mit diesem Filter ist kein Inhalt verfügbar" + +#: +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Bitte aktuellen Filter ändern oder entfernen" + +#: +msgctxt "#32456" +msgid "Show" +msgstr "Serie" + +#: +msgctxt "#32457" +msgid "By Show" +msgstr "Nach Serie" + +#: +msgctxt "#32458" +msgid "Episodes" +msgstr "Episoden" + +#: +msgctxt "#32459" +msgid "Offline Mode" +msgstr "Offline-Modus" + +#: +msgctxt "#32460" +msgid "Sign In" +msgstr "Anmelden" + +#: +msgctxt "#32461" +msgid "Albums" +msgstr "Alben" + +#: +msgctxt "#32462" +msgid "Artist" +msgstr "Künstler(in)" + +#: +msgctxt "#32463" +msgid "By Artist" +msgstr "Nach Künstler(in)" + +#: +msgctxt "#32464" +msgid "Player" +msgstr "Wiedergabe" + +#: +msgctxt "#32465" +msgid "Use skip step settings from Kodi" +msgstr "Skip-Schritte-Einstellung von Kodi benutzen" + +#: +msgctxt "#32466" +msgid "Automatically seek selected position after a delay" +msgstr "Automatisch verzögert zur ausgewählten Position springen" + +#: +msgctxt "#32467" +msgid "User Interface" +msgstr "Benutzeroberfläche" + +#: +msgctxt "#32468" +msgid "Show dynamic background art" +msgstr "Dynamische Hintergrundbilder aktivieren" + +#: +msgctxt "#32469" +msgid "Background art blur amount" +msgstr "Unschärfe der Hintergrundbilder" + +#: +msgctxt "#32470" +msgid "Background art opacity" +msgstr "Deckkraft der Hintergrundbilder" + +#: +msgctxt "#32471" +msgid "Use Plex/Kodi steps for timeline" +msgstr "Plex/Kodi-Skip-Schritte für die Zeitachse verwenden" + +#: +msgctxt "#32480" +msgid "Theme music" +msgstr "Titelmusik" + +#: +msgctxt "#32481" +msgid "Off" +msgstr "Aus" + +#: +msgctxt "#32482" +msgid "%(percentage)s %%" +msgstr "%(percentage)s %%" + +#: +msgctxt "#32483" +msgid "Hide Stream Info" +msgstr "Stream Info nicht anzeigen" + +#: +msgctxt "#32484" +msgid "Show Stream Info" +msgstr "Stream Info anzeigen" + +#: +msgctxt "#32485" +msgid "Go back instantly with the previous menu action in scrolled views" +msgstr "Mit der Previous-Menu-Aktion In gescrollten Ansichten sofort zurückgehen" + +#: +msgctxt "#32487" +msgid "Seek Delay" +msgstr "Vorspulverzögerung" + +#: +msgctxt "#32488" +msgid "Screensaver" +msgstr "Bildschirmschoner" + +#: +msgctxt "#32489" +msgid "Quiz Mode" +msgstr "Quiz-Modus" + +#: +msgctxt "#32490" +msgid "Collections" +msgstr "Sammlungen" + +#: +msgctxt "#32491" +msgid "Folders" +msgstr "Ordner" + +#: +msgctxt "#32492" +msgid "Kodi Subtitle Settings" +msgstr "Kodi Untertitel-Einstellungen" + +#: +msgctxt "#32493" +msgid "When a media file has a forced/foreign subtitle for a subtitle-enabled language, the Plex Media Server preselects it. This behaviour is usually not necessary and not configurable. This setting fixes that by ignoring the PMSs decision and selecting the same language without a forced flag if possible." +msgstr "Hat eine Mediendatei erzwungenge Untertitel für eine Sprache, bei der Untertitel erwünscht sind, wählt der Plex Media Server diesen standardmäßig aus. Das Verhalten ist normalerweise unerwünscht und nicht konfigurierbar. Diese Einstellung behebt das Problem, indem versucht wird, einen Untertitel der selben Sprache ohne Erzwungen-Markierung zu wählen." + +#: +msgctxt "#32495" +msgid "Skip intro" +msgstr "Intro überspringen" + +#: +msgctxt "#32496" +msgid "Skip credits" +msgstr "Abspann überspringen" + +#: +msgctxt "#32500" +msgid "Always show post-play screen (even for short videos)" +msgstr "Immer den Nachwiedergabe-Bildschirm anzeigen (auch bei kurzen Videos)" + +#: +msgctxt "#32501" +msgid "Time-to-wait between videos on post-play" +msgstr "Wartezeit zwischen Videos bei Nachwiedergabe" + +#: +msgctxt "#32505" +msgid "Visit media in video playlist instead of playing it" +msgstr "Medien in der Video-Wiedergabeliste anzeigen, anstatt sie abzuspielen" + +#: +msgctxt "#32521" +msgid "Skip Intro Button Timeout" +msgstr "Zeitlimit für die Schaltfläche Intro überspringen" + +#: +msgctxt "#32522" +msgid "Automatically Skip Intro" +msgstr "Intro automatisch überspringen" + +#: +msgctxt "#32523" +msgid "Automatically skip intros if available." +msgstr "Automatisches Überspringen von Intros, falls vorhanden. Überschreibt aktivierten Binge-Modus nicht." + +#: +msgctxt "#32524" +msgid "Set how long the skip intro button shows for." +msgstr "Festlegen, wie lange die Schaltfläche zum Überspringen des Intros angezeigt werden soll." + +#: +msgctxt "#32525" +msgid "Skip Credits Button Timeout" +msgstr "Zeitlimit für die Schaltfläche Abspann überspringen" + +#: +msgctxt "#32526" +msgid "Automatically Skip Credits" +msgstr "Automatisches Überspringen von Abspann" + +#: +msgctxt "#32527" +msgid "Automatically skip credits if available." +msgstr "Automatisches Überspringen von Abspann, falls vorhanden. Überschreibt aktivierten Binge-Modus nicht." + +#: +msgctxt "#32528" +msgid "Set how long the skip credits button shows for." +msgstr "Festlegen, wie lange die Schaltfläche zum Überspringen des Abspann angezeigt werden soll." + +#: +msgctxt "#32540" +msgid "Show when the current video will end in player" +msgstr "Im Player anzeigen, wann das aktuelle Video endet" + +#: +msgctxt "#32541" +msgid "Shows time left and at which time the media will end." +msgstr "Zeigt im Player an, wann und zu welchem Zeitpunkt das aktuelle Video endet." + +#: +msgctxt "#32542" +msgid "Show \"Ends at\" label for the end-time as well" +msgstr "\"Endet um\"-Label für die Endzeit auch anzeigen" + +#: +msgctxt "#32543" +msgid "Ends at" +msgstr "Endet um" + +#: +msgctxt "#32601" +msgid "Allow AV1" +msgstr "AV1 zulassen" + +#: +msgctxt "#32602" +msgid "Enable this if your hardware can handle AV1. Disable it to force transcoding." +msgstr "Diese Option aktivieren, wenn die Hardware AV1 verarbeiten kann. Deaktivieren, um die Transkodierung zu erzwingen." + +#: +msgctxt "#33101" +msgid "By Audience Rating" +msgstr "Nach Zuschauerwertung" + +#: +msgctxt "#33102" +msgid "Audience Rating" +msgstr "Zuschauerwertung" + +#: +msgctxt "#33103" +msgid "By my Rating" +msgstr "Nach meiner Bewertung" + +#: +msgctxt "#33104" +msgid "My Rating" +msgstr "Meine Bewertung" + +#: +msgctxt "#33105" +msgid "By Content Rating" +msgstr "Nach Inhaltsbewertung" + +#: +msgctxt "#33106" +msgid "Content Rating" +msgstr "Inhaltsbewertung" + +#: +msgctxt "#33107" +msgid "By Critic Rating" +msgstr "Nach kritischer Bewertung" + +#: +msgctxt "#33108" +msgid "Critic Rating" +msgstr "Kritikerwertung" + +#: +msgctxt "#33200" +msgid "Background Color" +msgstr "Hintergrundfarbe" + +#: +msgctxt "#33201" +msgid "Specify solid Background Color instead of using media images" +msgstr "Hintergrundfarbe anstatt Bilder verwenden" + +#: +msgctxt "#33400" +msgid "Use old compatibility profile" +msgstr "Altes Kompatibilitätsprofil verwenden" + +#: +msgctxt "#33401" +msgid "Uses the Chrome client profile instead of the custom one. Might fix rare issues with 3D playback." +msgstr "Benutzt das alte Chrome Client-Profil anstatt des angepassten. Könnte seltene Fehler beim Abspielen von 3D-Inhalten verhindern." + +#: +msgctxt "#32348" +msgid "movies" +msgstr "Filme" + +#: +msgctxt "#32466" +msgid "Automatically seek to the selected timeline position after a second" +msgstr "Nach Verzögerung automatisch zur aktuell gewählten Position springen" + +msgctxt "#33501" +msgid "Video played threshold" +msgstr "Video-abgespielt-Grenzwert" + +msgctxt "#33502" +msgid "Set this to the same value as your Plex server (Settings>Library>Video played threshold) to avoid certain pitfalls, Default: 90 %" +msgstr "Auf dem selben wert wie im Plex server setzen (Einstellungen>Mediathek>Video played threshold) um bestimmte Fehler zu umgehen, Standardwert: 90 %" + +msgctxt "#33503" +msgid "Use alternative hubs refresh" +msgstr "Alternative Aktualisierung der Hubs" + +msgctxt "#33504" +msgid "Refreshes all hubs for all libraries after an item's watch-state has changed, instead of only those likely affected. Use this if you find a hub that doesn't update properly." +msgstr "Aktualisiert alle Hubs aller Bibliotheken, anstatt nur die, die möglicherweise zutreffend sind, nachdem sich der Abspielstatus eines Items geändert hat. Benutzen, wenn sich ein Hub nicht aktualisiert." + +msgctxt "#33505" +msgid "Show intro skip button early" +msgstr "Intro überspringen früher anzeigen" + +msgctxt "#33506" +msgid "Show the intro skip button from the start of a video with an intro marker. The auto-skipping setting applies." +msgstr "Zeige den Intro-Überspringen-Knopf von Anfang an. Die Automatische-Intro-Überspringen-Einstellung wird angewandt. Überschreibt aktivierten Binge-Modus nicht." + +msgctxt "#33507" +msgid "Enabled" +msgstr "Aktiviert" + +msgctxt "#33508" +msgid "Disabled" +msgstr "Deaktiviert" + +msgctxt "#33509" +msgid "Early intro skip threshold (default: < 60s/1m)" +msgstr "Frühes Intro-Überspringen Grenzwert (default: < 60s/1m)" + +msgctxt "#33510" +msgid "When showing the intro skip button early, only do so if the intro starts within the first X seconds." +msgstr "Wenn der Into-Überspringen-Knopf früher angezeigt werden soll, nur anzeigen, wenn das Intro innerhalb der ersten X Sekunden startet." + +msgctxt "#33600" +msgid "System" +msgstr "System" + +msgctxt "#33601" +msgid "Show video chapters" +msgstr "Video-Kapitel anzeigen" + +msgctxt "#33602" +msgid "If available, show video chapters from the video-file instead of the timeline-big-seek-steps." +msgstr "Wenn verfügbar, Video-Kapitel aus der Video-Datei anstatt der Zeitleiste-Big-Seek-Schritte anzeigen." + +msgctxt "#33603" +msgid "Use virtual chapters" +msgstr "Virtuelle Kapitel verwenden" + +msgctxt "#33604" +msgid "When the above is enabled and no video chapters are available, simulate them by using the markers identified by the Plex Server (Intro, Credits)." +msgstr "Wenn die obrige aktiviert ist und keine Video-Kapitel verfügbar sind, virtuelle Kapitel aus den Plex Server Markern (Intro, Abspann) erzeugen." + +msgctxt "#33605" +msgid "Video Chapters" +msgstr "Video-Kapitel" + +msgctxt "#33606" +msgid "Virtual Chapters" +msgstr "Virtuelle Kapitel" + +msgctxt "#33607" +msgid "Chapter {}" +msgstr "Kapitel {}" + +msgctxt "#33608" +msgid "Intro" +msgstr "Intro" + +msgctxt "#33609" +msgid "Credits" +msgstr "Abspann" + +msgctxt "#33610" +msgid "Main" +msgstr "Haupt" + +msgctxt "#33611" +msgid "Chapters" +msgstr "Kapitel" + +msgctxt "#33612" +msgid "Markers" +msgstr "Markierungen" + +msgctxt "#33613" +msgid "Kodi Buffer Size (MB)" +msgstr "Kodi Puffergröße (MB)" + +msgctxt "#33614" +msgid "Set the Kodi Cache/Buffer size. Free: {} MB, Recommended: ~100 MB, Recommended max: {} MB, Default: 20 MB." +msgstr "Setzt die Kodi Cache/Puffer Größe. Frei: {} MB, empfohlen: ~100 MB, empfohlenes Max.: {} MB, Default: 20 MB." + +msgctxt "#33615" +msgid "{time} left" +msgstr "{time} übrig" + +msgctxt "#33616" +msgid "Addon Path" +msgstr "Addon-Pfad" + +msgctxt "#33617" +msgid "Userdata/Profile Path" +msgstr "Benutzerdaten-/Profilpfad" + +msgctxt "#33618" +msgid "TV binge-viewing mode" +msgstr "TV Binge-Viewing-Modus" + +msgctxt "#33619" +msgid "Automatically skips episode intros, credits and tries to skip episode recaps. Doesn\'t skip the intro of the first episode of a season.\n\nCan be disabled/enabled per TV show." +msgstr "Überspringt automatisch Intros und Abspänne von Episoden und versucht Recaps zu vermeiden. Überspringt das Intro der ersten Episode einer Staffel nicht." + +msgctxt "#33620" +msgid "Plex requests timeout (seconds)" +msgstr "Plex HTTP Zeitlimit (Sekunden)" + +msgctxt "#33621" +msgid "Set the (async and connection) timeout value of the Python requests library in seconds. Default: 5 seconds" +msgstr "Setzt das Zeitlimit für die Python Requests Bibliothek bei asynchronen Verbindungen und Verbindungsanfragen. Default: 5 Sekunden" + +msgctxt "#33622" +msgid "LAN reachability timeout (ms)" +msgstr "LAN-Erreichbarkeits-Zeitlimit (ms)" + +msgctxt "#33623" +msgid "When checking for Server-in-LAN reachability, use this timeout. Default: 10ms" +msgstr "Wenn die lokale Serververbindung im LAN überprüft wird, benutze dieses Zeitlimit. Default: 10ms" + +msgctxt "#33624" +msgid "Network" +msgstr "Netzwerk" + +msgctxt "#33625" +msgid "Smart LAN/local server discovery" +msgstr "Smartes LAN/lokale Server-Auffinden" + +msgctxt "#33626" +msgid "Checks whether servers returned from Plex.tv are actually local/in your LAN. For specific setups (e.g. Docker) Plex.tv might not properly detect a local server.\n\nNOTE: Only works on Kodi 19 or above." +msgstr "Überprüft, ob von Plex.tv zurückgegebene Server tatsächlich lokal/in Deinem LAN sind. Für bestimmte Setups (z. B. Docker) könnte Plex.tv einen lokalen Servern nicht als solchen entdecken.\n\nACHTUNG: Funktioniert nur unter Kodi 19 oder neuer." + +msgctxt "#33627" +msgid "Prefer LAN/local servers over security" +msgstr "LAN/lokale Server bevorzugen" + +msgctxt "#33628" +msgid "Prioritizes local connections over secure ones. Needs the proper setting in \"Allow Insecure Connections\" and the Plex Server's \"Secure connections\" at \"Preferred\". Can be used to enforce manual servers." +msgstr "Priorisiert lokale Verbindungen über sichere. Benötigt die korrekte Einstellung in \"Unsichere Verbindung erlauben\" und die Plex Server Einstellung \"Sichere Verbindungen\" auf \"Bevorzugt\". Kann verwendet werden um manuelle Server zu forcieren." + +msgctxt "#33629" +msgid "Auto-skip intro/credits offset" +msgstr "Auto-Überspringen-Versatz" + +msgctxt "#33630" +msgid "Intro/credits markers might be a little early in Plex. When auto skipping add (or subtract) this many seconds from the marker. This avoids cutting off content, while possibly skipping the marker a little late." +msgstr "Intro-/Abspann-Markierungen können ein wenig früh erscheinen in Plex. Wenn diese automatisch übersprungen werden sollen, addiere (oder subtrahiere) diese Menge an Sekunden. Das verhindert das verfrühte Abschneiden von Inhalten, kann aber zu etwas verspätetem Springen führen." + +msgctxt "#32631" +msgid "Playback (user-specific)" +msgstr "Wiedergabe (benutzerspezifisch)" + +msgctxt "#33632" +msgid "Server connectivity check timeout (seconds)" +msgstr "Server Konnektivitätscheck-Timeout (Sekunden)" + +msgctxt "#33633" +msgid "Set the maximum amount of time a server connection has to answer a connectivity request. Default: 2.5" +msgstr "Setzt die maximale Zeit, in der eine Serververbindung antworten muss. Voreinstellung: 2.5" + +msgctxt "#33634" +msgid "Combined Chapters" +msgstr "Kombinierte Kapitel" + +msgctxt "#33635" +msgid "Final Credits" +msgstr "Finaler Abspann" + +msgctxt "#32700" +msgid "Action on Sleep event" +msgstr "Aktion bei Sleep-Ereignis" + +msgctxt "#32701" +msgid "When Kodi receives a sleep event from the system, run the following action." +msgstr "Wenn Kodi ein Sleep-Ereignis vom System erhält, folgende Aktion ausführen." + +msgctxt "#32702" +msgid "Nothing" +msgstr "Nichts" + +msgctxt "#32703" +msgid "Stop playback" +msgstr "Abspielen stoppen" + +msgctxt "#32704" +msgid "Quit Kodi" +msgstr "Kodi beenden" + +msgctxt "#32705" +msgid "CEC Standby" +msgstr "CEC Standby" + +msgctxt "#32800" +msgid "Skipping intro" +msgstr "Überspringe Intro" + +msgctxt "#32801" +msgid "Skipping credits" +msgstr "Überspringe Abspann" + +msgctxt "#32900" +msgid "While playing back an item and seeking on the seekbar, automatically seek to the selected position after a delay instead of having to confirm the selection." +msgstr "Wird während des Abspielens die Position auf der Zeitleiste verändert, automatisch nach einer Verzögerung auf die gewählte Position springen, ohne diese bestätigen zu müssen." + +msgctxt "#32901" +msgid "Seek delay in seconds." +msgstr "Sprungverzögerung in Sekunden." + +msgctxt "#32902" +msgid "Kodi has its own skip step settings. Try to use them if they're configured instead of the default ones." +msgstr "Kodi besitzt seine eigenen Sprungeinstellungen. Versuche diese anstatt der standardmäßigen zu verwenden, sollten sie konfiguriert sein." + +msgctxt "#32903" +msgid "Use the above for seeking on the timeline as well." +msgstr "Benutze die obrige Einstellung auch für die Zeitleiste." + +msgctxt "#32904" +msgid "In seconds." +msgstr "In Sekunden." + +msgctxt "#32905" +msgid "Cancel post-play timer by pressing OK/SELECT" +msgstr "Post-Play Timer durch OK/AUSWAHL abbrechen" + +msgctxt "#32906" +msgid "Cancel skip marker timer with BACK" +msgstr "Markierung überspringen durch Zurück-Taste abbrechen" + +msgctxt "#32907" +msgid "When auto-skipping a marker, allow cancelling the timer by pressing BACK." +msgstr "Wenn eine Markierung mit einem Zeitgeber automatisch übersprungen wird, durch Zurück-Taste abbrechen." + +msgctxt "#32908" +msgid "Immediately skip marker with OK/SELECT" +msgstr "Markierung sofort überspringen mit OK/AUSWAHL" + +msgctxt "#32909" +msgid "When auto-skipping a marker with a timer, allow skipping immediately by pressing OK/SELECT." +msgstr "Wenn eine Markierung mit einem Zeitgeber automatisch übersprungen wird, durch OK/AUSWAHL-Taste sofort überspringen." + +msgctxt "#32912" +msgid "Show buffer-state on timeline" +msgstr "Zeige Puffer-Stand auf Zeitachse" + +msgctxt "#32913" +msgid "Shows the current Kodi buffer/cache state on the video player timeline." +msgstr "Zeigt den aktuellen Kodi Puffer/Cache-Stand auf der Videoplayer Zeitachse." + +msgctxt "#32914" +msgid "Loading" +msgstr "Lädt" + +msgctxt "#32915" +msgid "Slow connection" +msgstr "Langsame Verbindung" + +msgctxt "#32916" +msgid "Use with a wonky/slow connection, e.g. in a hotel room. Adjusts the UI to visually wait for item refreshes and waits for the buffer to fill when starting playback. Automatically sets readfactor=20, requires Kodi restart." +msgstr "Bei langsamer Verbindung benutzen, z.B. im Hotel. Passt die Oberfläche visuell an um auf Media Auffrischungen zu warten und füllt den Kodi Puffer vor dem Abspielen. Setzt automatisch Kodi Cache readfactor=20, benötigt einen Kodi Neustart." + +msgctxt "#32917" +msgid "Couldn't fill buffer in time ({}s)" +msgstr "Konnte den Puffer nicht schnell genug füllen ({} Sek.)" + +msgctxt "#32918" +msgid "Buffer wait timeout (seconds)" +msgstr "Puffer-Wartezeit (Sekunden)" + +msgctxt "#32919" +msgid "When slow connection is enabled in the addon, wait this long for the buffer to fill. Default: 120 s" +msgstr "Wenn Langsame Verbindung im Addon aktiviert ist, warte so lange bis der Puffer gefüllt ist. Voreinstellung: 120 Sek." + +msgctxt "#32920" +msgid "Insufficient buffer wait (seconds)" +msgstr "Ungenügender Puffer Wartezeit (Sekunden)" + +msgctxt "#32921" +msgid "When slow connection is enabled in the addon and the configured cache/buffer isn't big enough for us to determine its fill state, wait this long when starting playback. Default: 10 s" +msgstr "Wenn \"Langsame Verbindung\" im Addon aktiviert ist und der konfigurierte Puffer/Cache nicht groß genug ist, um seinen Füllstand zu ermitteln, wird so lange gewartet, bevor die Wiedergabe gestartet wird. Voreinstellung: 10 Sek." + +msgctxt "#32922" +msgid "Kodi Cache Readfactor" +msgstr "Kodi Puffer Readfactor" + +msgctxt "#32923" +msgid "Sets the Kodi cache readfactor value. Default: {0}, recommended: {1}. With \"Slow connection\" enabled this will be set to {2}, as otherwise the cache doesn't fill fast/aggressively enough." +msgstr "Setzt den Kodi Cache/Puffer readfactor Wert. Standard: {0}, Empfohlen: {1}. Bei aktivem \"Langsame Verbindung\" wird dies automatisch auf {2} gesetzt, da ansonsten der Cache nicht schnell/agressiv genug gefüllt wird." + +msgctxt "#32924" +msgid "Minimize" +msgstr "Minimieren" + +msgctxt "#32925" +msgid "Playback Settings" +msgstr "Abspieleinstell." + +msgctxt "#32926" +msgid "Wrong pin entered!" +msgstr "Falsche PIN eingegeben!" + +msgctxt "#32927" +msgid "Use episode thumbnails in continue hub" +msgstr "Vorschaubild für Episoden im Fortsetzen-Hub verwenden" + +msgctxt "#32928" +msgid "Instead of using media artwork, use thumbnails for episodes in the continue hub on the home screen if available." +msgstr "Verwende Vorschaubilder anstatt Media-Artwork für Episoden im Fortsetzen-Hub auf der Start-Ansicht." + +msgctxt "#32929" +msgid "Use legacy background fallback image" +msgstr "Veraltetes Ausweich-Hintergrundbild verwenden" + +msgctxt "#32930" +msgid "Previous Subtitle" +msgstr "Vorheriger Untertitel" + +msgctxt "#32931" +msgid "Audio/Subtitles" +msgstr "Audio/Untertitel" + +msgctxt "#32932" +msgid "Show subtitle quick-actions button" +msgstr "Zeige Untertitel-Schnellaktionen-Knopf" + +msgctxt "#32933" +msgid "Show FFWD/RWD buttons" +msgstr "Zeige Vorspulen-Zurückspulen-Knopf" + +msgctxt "#32934" +msgid "Show repeat button" +msgstr "Zeige Wiederholen-Knopf" + +msgctxt "#32935" +msgid "Show shuffle button" +msgstr "Zeige Zufällige-Wiedergabe-Knopf" + +msgctxt "#32936" +msgid "Show playlist button" +msgstr "Zeige Wiedergabeliste-Knopf" + +msgctxt "#32937" +msgid "Show prev/next button" +msgstr "Zeige Vorheriger/Nächster-Knopf" + +msgctxt "#32938" +msgid "Only for Episodes" +msgstr "Nur bei Episoden" + +msgctxt "#32939" +msgid "Only applies to video player UI" +msgstr "Gilt nur für die Video Abspieloberfläche" + +msgctxt "#32940" +msgid "Player UI" +msgstr "Abspieloberfläche" + +msgctxt "#32941" +msgid "Forced subtitles fix" +msgstr "Erzwungene Untertitel beheben" + +msgctxt "#32942" +msgid "Other seasons" +msgstr "Weitere Staffeln" + +msgctxt "#32943" +msgid "Crossfade dynamic background art" +msgstr "Dynamische Hintergrundbilder überblenden" + +msgctxt "#32944" +msgid "Burn-in SSA subtitles (DirectStream)" +msgstr "SSA-Untertitel einbrennen (DirectStream)" + +msgctxt "#32945" +msgid "When Direct Streaming instruct the Plex Server to burn in SSA/ASS subtitles (thus transcoding the video stream). If disabled it will not touch the video stream, but will convert the subtitle to unstyled text." +msgstr "Wird Direct Streaming verwendet, den Plex Server dazu bringen, SSA/ASS-Untertitel einzubrennen (also den Video Stream zu transcoden). Wenn deaktiviert, wird dieser den Video Stream nicht anfassen, jedoch den Untertitel als reinen Text anzeigen." + +msgctxt "#32946" +msgid "Stop video playback on idle after" +msgstr "Bei Inaktivität Video stoppen nach" + +msgctxt "#32947" +msgid "Stop video playback on screensaver" +msgstr "Video stoppen bei Bildschirmschoner" + +msgctxt "#32948" +msgid "Allow auto-skip when transcoding" +msgstr "Überspringen beim Transkodieren erlauben" + +msgctxt "#32949" +msgid "When transcoding/DirectStreaming, allow auto-skip functionality." +msgstr "Beim Transkodieren/DirectStream die automatische Überspringen-Funktionalität erlauben." + +msgctxt "#32950" +msgid "Use extended title for subtitles" +msgstr "Erweiterten Titel für Untertitel verwenden" + +msgctxt "#32951" +msgid "When displaying subtitles use the extendedDisplayTitle Plex exposes." +msgstr "Verwende erweiterte Namen wenn Untertitelnamen angezeigt werden." + +msgctxt "#32952" +msgid "Dialog-Flackern beheben" +msgstr "" + +msgctxt "#32953" +msgid "Reviews" +msgstr "Kritik" + +msgctxt "#32954" +msgid "Needs Kodi restart. WARNING: This will overwrite advancedsettings.xml!\n\nTo customize other cache/network-related values, copy \"script.plexmod/pm4k_cache_template.xml\" to profile folder and edit it to your liking. (See About section for the file paths)" +msgstr "Benötigt Kodi Neustart. ACHTUNG: Überschreibt advancedsettings.xml!\n\nUm weitere Cache-/Netzwerkbezogene Werte zu ändern, kopiere \"script.plexmod/pm4k_cache_template.xml\" in den Profilordner und editiere sie. (Für die Dateipfade schaue in die Über-Sektion)" + +msgctxt "#32955" +msgid "Use Kodi keyboard for searching" +msgstr "Kodi-Tastatur bei der Suche verwenden" + +msgctxt "#32956" +msgid "Poster resolution scaling" +msgstr "Poster Auflösungsskalierung" + +msgctxt "#32957" +msgid "Scales the resolution of all posters/thumbnails for better image quality. May impact PMS/PM4K performance. Recommended: 2.5 (250 %) for good quality. Needs addon restart." +msgstr "Skaliert die Auflösung aller Poster/Vorschaubilder für bessere Bildqualität. Kann die PMS/PM4K Performance beeinflussen. Empfohlen: 2.5 (250 %) für gute Qualität. Benötigt Addon-Neustart." diff --git a/script.plexmod/resources/language/resource.language.en_gb/strings.po b/script.plexmod/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 000000000..0dcee3ae9 --- /dev/null +++ b/script.plexmod/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,1639 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" + +msgctxt "#32000" +msgid "Main" +msgstr "" + +msgctxt "#32001" +msgid "Original" +msgstr "" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "" + +msgctxt "#32027" +msgid "Force" +msgstr "" + +msgctxt "#32028" +msgid "Always" +msgstr "" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "" + +msgctxt "#32030" +msgid "Auto" +msgstr "" + +msgctxt "#32031" +msgid "Burn-in Subtitles" +msgstr "" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "" + +msgctxt "#32033" +msgid "Never" +msgstr "" + +msgctxt "#32034" +msgid "On Same network" +msgstr "" + +msgctxt "#32035" +msgid "Always" +msgstr "" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "" + +msgctxt "#32048" +msgid "Audio" +msgstr "" + +msgctxt "#32049" +msgid "Advanced" +msgstr "" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "" + +msgctxt "#32051" +msgid "Privacy" +msgstr "" + +msgctxt "#32052" +msgid "About" +msgstr "" + +msgctxt "#32053" +msgid "Video" +msgstr "" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "" + +msgctxt "#32058" +msgid "Never exceed original audio codec" +msgstr "" + +msgctxt "#32059" +msgid "When transcoding audio, never exceed the original audio bitrate or channel count on the same codec." +msgstr "" + +msgctxt "#32060" +msgid "Use Kodi audio channels" +msgstr "" + +msgctxt "#32061" +msgid "When transcoding audio, target the audio channels set in Kodi." +msgstr "" + +msgctxt "#32062" +msgid "Transcode audio to AC3" +msgstr "" + +msgctxt "#32063" +msgid "Transcode audio to AC3 in certain conditions (useful for passthrough)." +msgstr "" + +msgctxt "#32064" +msgid "Treat DTS like AC3" +msgstr "" + +msgctxt "#32065" +msgid "When any of the force AC3 settings are enabled, treat DTS the same as AC3 (useful for Optical passthrough)" +msgstr "" + +msgctxt "#32066" +msgid "Force audio to AC3" +msgstr "" + +msgctxt "#32067" +msgid "Only force multichannel audio to AC3" +msgstr "" + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "" + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "" + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "" + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "" + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "" + +msgctxt "#32201" +msgid "Trailer" +msgstr "" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "" + +msgctxt "#32203" +msgid "Interview" +msgstr "" + +msgctxt "#32204" +msgid "Music Video" +msgstr "" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "" + +msgctxt "#32206" +msgid "Scene" +msgstr "" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "" + +msgctxt "#32209" +msgid "Concert" +msgstr "" + +msgctxt "#32210" +msgid "Featurette" +msgstr "" + +msgctxt "#32211" +msgid "Short" +msgstr "" + +msgctxt "#32212" +msgid "Other" +msgstr "" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "" + +msgctxt "#32303" +msgid "Season" +msgstr "" + +msgctxt "#32304" +msgid "Episode" +msgstr "" + +msgctxt "#32305" +msgid "Extras" +msgstr "" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "" + +msgctxt "#32307" +msgid "More" +msgstr "" + +msgctxt "#32308" +msgid "Available" +msgstr "" + +msgctxt "#32309" +msgid "None" +msgstr "" + +msgctxt "#32310" +msgid "S" +msgstr "" + +msgctxt "#32311" +msgid "E" +msgstr "" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "" + +msgctxt "#32314" +msgid "In Progress" +msgstr "" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "" + +msgctxt "#32316" +msgid "Resume" +msgstr "" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "" + +msgctxt "#32322" +msgid "Delete" +msgstr "" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "" + +msgctxt "#32325" +msgid "Play Next" +msgstr "" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "" + +msgctxt "#32328" +msgid "Yes" +msgstr "" + +msgctxt "#32329" +msgid "No" +msgstr "" + +msgctxt "#32330" +msgid "Message" +msgstr "" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "" + +msgctxt "#32332" +msgid "Home" +msgstr "" + +msgctxt "#32333" +msgid "Playlists" +msgstr "" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "" + +msgctxt "#32336" +msgid "Exit" +msgstr "" + +msgctxt "#32337" +msgid "Cancel" +msgstr "" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "" + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "" + +msgctxt "#32342" +msgid "Switch User" +msgstr "" + +msgctxt "#32343" +msgid "Settings" +msgstr "" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "" + +msgctxt "#32345" +msgid "All" +msgstr "" + +msgctxt "#32346" +msgid "By Name" +msgstr "" + +msgctxt "#32347" +msgid "Artists" +msgstr "" + +msgctxt "#32348" +msgid "Movies" +msgstr "" + +msgctxt "#32349" +msgid "photos" +msgstr "" + +msgctxt "#32350" +msgid "Shows" +msgstr "" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "" + +msgctxt "#32352" +msgid "Date Added" +msgstr "" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "" + +msgctxt "#32354" +msgid "Release Date" +msgstr "" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "" + +msgctxt "#32357" +msgid "By Name" +msgstr "" + +msgctxt "#32358" +msgid "Name" +msgstr "" + +msgctxt "#32359" +msgid "By Rating" +msgstr "" + +msgctxt "#32360" +msgid "Rating" +msgstr "" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "" + +msgctxt "#32362" +msgid "Resolution" +msgstr "" + +msgctxt "#32363" +msgid "By Duration" +msgstr "" + +msgctxt "#32364" +msgid "Duration" +msgstr "" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "" + +msgctxt "#32366" +msgid "First Aired" +msgstr "" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "" + +msgctxt "#32370" +msgid "Date Played" +msgstr "" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "" + +msgctxt "#32372" +msgid "Play Count" +msgstr "" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "" + +msgctxt "#32375" +msgid "No filters available" +msgstr "" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "" + +msgctxt "#32377" +msgid "Year" +msgstr "" + +msgctxt "#32378" +msgid "Decade" +msgstr "" + +msgctxt "#32379" +msgid "Genre" +msgstr "" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "" + +msgctxt "#32381" +msgid "Network" +msgstr "" + +msgctxt "#32382" +msgid "Collection" +msgstr "" + +msgctxt "#32383" +msgid "Director" +msgstr "" + +msgctxt "#32384" +msgid "Actor" +msgstr "" + +msgctxt "#32385" +msgid "Country" +msgstr "" + +msgctxt "#32386" +msgid "Studio" +msgstr "" + +msgctxt "#32387" +msgid "Labels" +msgstr "" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "" + +msgctxt "#32390" +msgid "Aperture" +msgstr "" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "" + +msgctxt "#32392" +msgid "Lens" +msgstr "" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "" + +msgctxt "#32394" +msgid "Music" +msgstr "" + +msgctxt "#32395" +msgid "Audio" +msgstr "" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "" + +msgctxt "#32397" +msgid "Quality" +msgstr "" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "" + +msgctxt "#32401" +msgid "Directors" +msgstr "" + +msgctxt "#32402" +msgid "Writer" +msgstr "" + +msgctxt "#32403" +msgid "Writers" +msgstr "" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "" + +msgctxt "#32411" +msgid "Unknown" +msgstr "" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "" + +msgctxt "#32414" +msgid "Clear" +msgstr "" + +msgctxt "#32415" +msgid "Edit" +msgstr "" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "" + +msgctxt "#32418" +msgid "Creator" +msgstr "" + +msgctxt "#32419" +msgid "Cast" +msgstr "" + +msgctxt "#32420" +msgid "Disc" +msgstr "" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "" + +msgctxt "#32422" +msgid "Exit" +msgstr "" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "" + +msgctxt "#32424" +msgid "Suspend" +msgstr "" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "" + +msgctxt "#32426" +msgid "Reboot" +msgstr "" + +msgctxt "#32427" +msgid "Failed" +msgstr "" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "" + +msgctxt "#32430" +msgid "Discovery" +msgstr "" + +msgctxt "#32431" +msgid "Search" +msgstr "" + +msgctxt "#32432" +msgid "Space" +msgstr "" + +msgctxt "#32433" +msgid "Clear" +msgstr "" + +msgctxt "#32434" +msgid "Searching..." +msgstr "" + +msgctxt "#32435" +msgid "No Results" +msgstr "" + +msgctxt "#32436" +msgid "Paused" +msgstr "" + +msgctxt "#32437" +msgid "Welcome" +msgstr "" + +msgctxt "#32438" +msgid "Previous" +msgstr "" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "" + +msgctxt "#32440" +msgid "On Deck" +msgstr "" + +msgctxt "#32441" +msgid "Unknown" +msgstr "" + +msgctxt "#32442" +msgid "Embedded" +msgstr "" + +msgctxt "#32443" +msgid "Forced" +msgstr "" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "" + +msgctxt "#32445" +msgid "Mono" +msgstr "" + +msgctxt "#32446" +msgid "Stereo" +msgstr "" + +msgctxt "#32447" +msgid "None" +msgstr "" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "" + +msgctxt "#32450" +msgid "Choose Version" +msgstr "" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "" + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "" + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "" + +msgctxt "#32456" +msgid "Show" +msgstr "" + +msgctxt "#32457" +msgid "By Show" +msgstr "" + +msgctxt "#32458" +msgid "Episodes" +msgstr "" + +msgctxt "#32459" +msgid "Offline Mode" +msgstr "" + +msgctxt "#32460" +msgid "Sign In" +msgstr "" + +msgctxt "#32461" +msgid "Albums" +msgstr "" + +msgctxt "#32462" +msgid "Artist" +msgstr "" + +msgctxt "#32463" +msgid "By Artist" +msgstr "" + +msgctxt "#32464" +msgid "Player" +msgstr "" + +msgctxt "#32465" +msgid "Use skip step settings from Kodi" +msgstr "" + +msgctxt "#32466" +msgid "Automatically seek selected position after a delay" +msgstr "" + +msgctxt "#32467" +msgid "User Interface" +msgstr "" + +msgctxt "#32468" +msgid "Show dynamic background art" +msgstr "" + +msgctxt "#32469" +msgid "Background art blur amount" +msgstr "" + +msgctxt "#32470" +msgid "Background art opacity" +msgstr "" + +msgctxt "#32471" +msgid "Use Plex/Kodi steps for timeline" +msgstr "" + +msgctxt "#32480" +msgid "Theme music" +msgstr "" + +msgctxt "#32481" +msgid "Off" +msgstr "" + +msgctxt "#32482" +msgid "%(percentage)s %%" +msgstr "" + +msgctxt "#32483" +msgid "Hide Stream Info" +msgstr "" + +msgctxt "#32484" +msgid "Show Stream Info" +msgstr "" + +msgctxt "#32485" +msgid "Go back instantly with the previous menu action in scrolled views" +msgstr "" + +msgctxt "#32487" +msgid "Seek Delay" +msgstr "" + +msgctxt "#32488" +msgid "Screensaver" +msgstr "" + +msgctxt "#32489" +msgid "Quiz Mode" +msgstr "" + +msgctxt "#32490" +msgid "Collections" +msgstr "" + +msgctxt "#32491" +msgid "Folders" +msgstr "" + +msgctxt "#32492" +msgid "Kodi Subtitle Settings" +msgstr "" + +msgctxt "#32493" +msgid "When a media file has a forced/foreign subtitle for a subtitle-enabled language, the Plex Media Server preselects it. This behaviour is usually not necessary and not configurable. This setting fixes that by ignoring the PMSs decision and selecting the same language without a forced flag if possible." +msgstr "" + +msgctxt "#32495" +msgid "Skip intro" +msgstr "" + +msgctxt "#32496" +msgid "Skip credits" +msgstr "" + + +msgctxt "#32500" +msgid "Always show post-play screen (even for short videos)" +msgstr "" + +msgctxt "#32501" +msgid "Time-to-wait between videos on post-play" +msgstr "" + +msgctxt "#32505" +msgid "Visit media in video playlist instead of playing it" +msgstr "" + +msgctxt "#32521" +msgid "Skip Intro Button Timeout" +msgstr "" + +msgctxt "#32522" +msgid "Automatically Skip Intro" +msgstr "" + +msgctxt "#32523" +msgid "Automatically skip intros if available. Doesn't override enabled binge mode." +msgstr "" + +msgctxt "#32524" +msgid "Set how long the skip intro button shows for." +msgstr "" + +msgctxt "#32525" +msgid "Skip Credits Button Timeout" +msgstr "" + +msgctxt "#32526" +msgid "Automatically Skip Credits" +msgstr "" + +msgctxt "#32527" +msgid "Automatically skip credits if available. Doesn't override enabled binge mode." +msgstr "" + +msgctxt "#32528" +msgid "Set how long the skip credits button shows for." +msgstr "" + +msgctxt "#32540" +msgid "Show when the current video will end in player" +msgstr "" + +msgctxt "#32541" +msgid "Shows time left and at which time the media will end." +msgstr "" + +msgctxt "#32542" +msgid "Show \"Ends at\" label for the end-time as well" +msgstr "" + +msgctxt "#32543" +msgid "Ends at" +msgstr "" + +msgctxt "#32601" +msgid "Allow AV1" +msgstr "" + +msgctxt "#32602" +msgid "Enable this if your hardware can handle AV1. Disable it to force transcoding." +msgstr "" + +msgctxt "#33101" +msgid "By Audience Rating" +msgstr "" + +msgctxt "#33102" +msgid "Audience Rating" +msgstr "" + +msgctxt "#33103" +msgid "By my Rating" +msgstr "" + +msgctxt "#33104" +msgid "My Rating" +msgstr "" + +msgctxt "#33105" +msgid "By Content Rating" +msgstr "" + +msgctxt "#33106" +msgid "Content Rating" +msgstr "" + +msgctxt "#33107" +msgid "By Critic Rating" +msgstr "" + +msgctxt "#33108" +msgid "Critic Rating" +msgstr "" + +msgctxt "#33200" +msgid "Background Color" +msgstr "" + +msgctxt "#33201" +msgid "Specify solid Background Color instead of using media images" +msgstr "" + +msgctxt "#33400" +msgid "Use old compatibility profile" +msgstr "" + +msgctxt "#33401" +msgid "Uses the Chrome client profile instead of the custom one. Might fix rare issues with 3D playback." +msgstr "" + +msgctxt "#33501" +msgid "Video played threshold" +msgstr "" + +msgctxt "#33502" +msgid "Set this to the same value as your Plex server (Settings>Library>Video played threshold) to avoid certain pitfalls, Default: 90 %" +msgstr "" + +msgctxt "#33503" +msgid "Use alternative hubs refresh" +msgstr "" + +msgctxt "#33504" +msgid "Refreshes all hubs for all libraries after an item's watch-state has changed, instead of only those likely affected. Use this if you find a hub that doesn't update properly." +msgstr "" + +msgctxt "#33505" +msgid "Show intro skip button early" +msgstr "" + +msgctxt "#33506" +msgid "Show the intro skip button from the start of a video with an intro marker. The auto-skipping setting applies. Doesn\'t override enabled binge mode." +msgstr "" + +msgctxt "#33507" +msgid "Enabled" +msgstr "" + +msgctxt "#33508" +msgid "Disabled" +msgstr "" + +msgctxt "#33509" +msgid "Early intro skip threshold (default: < 60s/1m)" +msgstr "" + +msgctxt "#33510" +msgid "When showing the intro skip button early, only do so if the intro occurs within the first X seconds." +msgstr "" + +msgctxt "#33600" +msgid "System" +msgstr "" + +msgctxt "#33601" +msgid "Show video chapters" +msgstr "" + +msgctxt "#33602" +msgid "If available, show video chapters from the video-file instead of the timeline-big-seek-steps." +msgstr "" + +msgctxt "#33603" +msgid "Use virtual chapters" +msgstr "" + +msgctxt "#33604" +msgid "When the above is enabled and no video chapters are available, simulate them by using the markers identified by the Plex Server (Intro, Credits)." +msgstr "" + +msgctxt "#33605" +msgid "Video Chapters" +msgstr "" + +msgctxt "#33606" +msgid "Virtual Chapters" +msgstr "" + +msgctxt "#33607" +msgid "Chapter {}" +msgstr "" + +msgctxt "#33608" +msgid "Intro" +msgstr "" + +msgctxt "#33609" +msgid "Credits" +msgstr "" + +msgctxt "#33610" +msgid "Main" +msgstr "" + +msgctxt "#33611" +msgid "Chapters" +msgstr "" + +msgctxt "#33612" +msgid "Markers" +msgstr "" + +msgctxt "#33613" +msgid "Kodi Buffer Size (MB)" +msgstr "" + +msgctxt "#33614" +msgid "Set the Kodi Cache/Buffer size. Free: {} MB, Recommended: ~100 MB, Recommended max: {} MB, Default: 20 MB." +msgstr "" + +msgctxt "#33615" +msgid "{time} left" +msgstr "" + +msgctxt "#33616" +msgid "Addon Path" +msgstr "" + +msgctxt "#33617" +msgid "Userdata/Profile Path" +msgstr "" + +msgctxt "#33618" +msgid "TV binge-viewing mode" +msgstr "" + +msgctxt "#33619" +msgid "Automatically skips episode intros, credits and tries to skip episode recaps. Doesn\'t skip the intro of the first episode of a season.\n\nCan be disabled/enabled per TV show." +msgstr "" + +msgctxt "#33620" +msgid "Plex requests timeout (seconds)" +msgstr "" + +msgctxt "#33621" +msgid "Set the (async and connection) timeout value of the Python requests library in seconds. Default: 5" +msgstr "" + +msgctxt "#33622" +msgid "LAN reachability timeout (ms)" +msgstr "" + +msgctxt "#33623" +msgid "When checking for LAN reachability, use this timeout. Default: 10ms" +msgstr "" + +msgctxt "#33624" +msgid "Network" +msgstr "" + +msgctxt "#33625" +msgid "Smart LAN/local server discovery" +msgstr "" + +msgctxt "#33626" +msgid "Checks whether servers returned from Plex.tv are actually local/in your LAN. For specific setups (e.g. Docker) Plex.tv might not properly detect a local server.\n\nNOTE: Only works on Kodi 19 or above." +msgstr "" + +msgctxt "#33627" +msgid "Prefer LAN/local servers over security" +msgstr "" + +msgctxt "#33628" +msgid "Prioritizes local connections over secure ones. Needs the proper setting in \"Allow Insecure Connections\" and the Plex Server's \"Secure connections\" at \"Preferred\". Can be used to enforce manual servers." +msgstr "" + +msgctxt "#33629" +msgid "Auto-skip intro/credits offset" +msgstr "" + +msgctxt "#33630" +msgid "Intro/credits markers might be a little early in Plex. When auto skipping add (or subtract) this many seconds from the marker. This avoids cutting off content, while possibly skipping the marker a little late." +msgstr "" + +msgctxt "#32631" +msgid "Playback (user-specific)" +msgstr "" + +msgctxt "#33632" +msgid "Server connectivity check timeout (seconds)" +msgstr "" + +msgctxt "#33633" +msgid "Set the maximum amount of time a server connection has to answer a connectivity request. Default: 2.5" +msgstr "" + +msgctxt "#33634" +msgid "Combined Chapters" +msgstr "" + +msgctxt "#33635" +msgid "Final Credits" +msgstr "" + +msgctxt "#32700" +msgid "Action on Sleep event" +msgstr "" + +msgctxt "#32701" +msgid "When Kodi receives a sleep event from the system, run the following action." +msgstr "" + +msgctxt "#32702" +msgid "Nothing" +msgstr "" + +msgctxt "#32703" +msgid "Stop playback" +msgstr "" + +msgctxt "#32704" +msgid "Quit Kodi" +msgstr "" + +msgctxt "#32705" +msgid "CEC Standby" +msgstr "" + +msgctxt "#32800" +msgid "Skipping intro" +msgstr "" + +msgctxt "#32801" +msgid "Skipping credits" +msgstr "" + +msgctxt "#32900" +msgid "While playing back an item and seeking on the seekbar, automatically seek to the selected position after a delay instead of having to confirm the selection." +msgstr "" + +msgctxt "#32901" +msgid "Seek delay in seconds." +msgstr "" + +msgctxt "#32902" +msgid "Kodi has its own skip step settings. Try to use them if they're configured instead of the default ones." +msgstr "" + +msgctxt "#32903" +msgid "Use the above for seeking on the timeline as well." +msgstr "" + +msgctxt "#32904" +msgid "In seconds." +msgstr "" + +msgctxt "#32905" +msgid "Cancel post-play timer by pressing OK/SELECT" +msgstr "" + +msgctxt "#32906" +msgid "Cancel skip marker timer with BACK" +msgstr "" + +msgctxt "#32907" +msgid "When auto-skipping a marker, allow cancelling the timer by pressing BACK." +msgstr "" + +msgctxt "#32908" +msgid "Immediately skip marker with OK/SELECT" +msgstr "" + +msgctxt "#32909" +msgid "When auto-skipping a marker with a timer, allow skipping immediately by pressing OK/SELECT." +msgstr "" + +msgctxt "#32912" +msgid "Show buffer-state on timeline" +msgstr "" + +msgctxt "#32913" +msgid "Shows the current Kodi buffer/cache state on the video player timeline." +msgstr "" + +msgctxt "#32914" +msgid "Loading" +msgstr "" + +msgctxt "#32915" +msgid "Slow connection" +msgstr "" + +msgctxt "#32916" +msgid "Use with a wonky/slow connection, e.g. in a hotel room. Adjusts the UI to visually wait for item refreshes and waits for the buffer to fill when starting playback. Automatically sets readfactor=20, requires Kodi restart." +msgstr "" + +msgctxt "#32917" +msgid "Couldn't fill buffer in time ({}s)" +msgstr "" + +msgctxt "#32918" +msgid "Buffer wait timeout (seconds)" +msgstr "" + +msgctxt "#32919" +msgid "When slow connection is enabled in the addon, wait this long for the buffer to fill. Default: 120 s" +msgstr "" + +msgctxt "#32920" +msgid "Insufficient buffer wait (seconds)" +msgstr "" + +msgctxt "#32921" +msgid "When slow connection is enabled in the addon and the configured buffer isn't big enough for us to determine its fill state, wait this long when starting playback. Default: 10 s" +msgstr "" + +msgctxt "#32922" +msgid "Kodi Cache Readfactor" +msgstr "" + +msgctxt "#32923" +msgid "Sets the Kodi cache readfactor value. Default: {0}, recommended: {1}. With \"Slow connection\" enabled this will be set to {2}, as otherwise the cache doesn't fill fast/aggressively enough." +msgstr "" + +msgctxt "#32924" +msgid "Minimize" +msgstr "" + +msgctxt "#32925" +msgid "Playback Settings" +msgstr "" + +msgctxt "#32926" +msgid "Wrong pin entered!" +msgstr "" + +msgctxt "#32927" +msgid "Use episode thumbnails in continue hub" +msgstr "" + +msgctxt "#32928" +msgid "Instead of using media artwork, use thumbnails for episodes in the continue hub on the home screen if available." +msgstr "" + +msgctxt "#32929" +msgid "Use legacy background fallback image" +msgstr "" + +msgctxt "#32930" +msgid "Previous Subtitle" +msgstr "" + +msgctxt "#32931" +msgid "Audio/Subtitles" +msgstr "" + +msgctxt "#32932" +msgid "Show subtitle quick-actions button" +msgstr "" + +msgctxt "#32933" +msgid "Show FFWD/RWD buttons" +msgstr "" + +msgctxt "#32934" +msgid "Show repeat button" +msgstr "" + +msgctxt "#32935" +msgid "Show shuffle button" +msgstr "" + +msgctxt "#32936" +msgid "Show playlist button" +msgstr "" + +msgctxt "#32937" +msgid "Show prev/next button" +msgstr "" + +msgctxt "#32938" +msgid "Only for Episodes" +msgstr "" + +msgctxt "#32939" +msgid "Only applies to video player UI" +msgstr "" + +msgctxt "#32940" +msgid "Player UI" +msgstr "" + +msgctxt "#32941" +msgid "Forced subtitles fix" +msgstr "" + +msgctxt "#32942" +msgid "Other seasons" +msgstr "" + +msgctxt "#32943" +msgid "Crossfade dynamic background art" +msgstr "" + +msgctxt "#32944" +msgid "Burn-in SSA subtitles (DirectStream)" +msgstr "" + +msgctxt "#32945" +msgid "When Direct Streaming instruct the Plex Server to burn in SSA/ASS subtitles (thus transcoding the video stream). If disabled it will not touch the video stream, but will convert the subtitle to unstyled text." +msgstr "" + +msgctxt "#32946" +msgid "Stop video playback on idle after" +msgstr "" + +msgctxt "#32947" +msgid "Stop video playback on screensaver" +msgstr "" + +msgctxt "#32948" +msgid "Allow auto-skip when transcoding" +msgstr "" + +msgctxt "#32949" +msgid "When transcoding/DirectStreaming, allow auto-skip functionality." +msgstr "" + +msgctxt "#32950" +msgid "Use extended title for subtitles" +msgstr "" + +msgctxt "#32951" +msgid "When displaying subtitles use the extendedDisplayTitle Plex exposes." +msgstr "" + +msgctxt "#32952" +msgid "Dialog flicker fix" +msgstr "" + +msgctxt "#32953" +msgid "Reviews" +msgstr "" + +msgctxt "#32954" +msgid "Needs Kodi restart. WARNING: This will overwrite advancedsettings.xml!\n\nTo customize other cache/network-related values, copy \"script.plexmod/pm4k_cache_template.xml\" to profile folder and edit it to your liking. (See About section for the file paths)" +msgstr "" + +msgctxt "#32955" +msgid "Use Kodi keyboard for searching" +msgstr "" + +msgctxt "#32956" +msgid "Poster resolution scaling" +msgstr "" + +msgctxt "#32957" +msgid "Scales the resolution of all posters/thumbnails for better image quality. May impact PMS/PM4K performance. Recommended: 2.5 (250 %) for good quality. Needs addon restart." +msgstr "" diff --git a/script.plexmod/resources/language/resource.language.es_es/strings.po b/script.plexmod/resources/language/resource.language.es_es/strings.po new file mode 100644 index 000000000..b8c6adca8 --- /dev/null +++ b/script.plexmod/resources/language/resource.language.es_es/strings.po @@ -0,0 +1,951 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: 2020-05-16 10:52+0200\n" +"Language-Team: LANGUAGE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: es\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Last-Translator: \n" +"X-Generator: Poedit 2.0.2\n" + +msgctxt "#32000" +msgid "Main" +msgstr "Principal" + +msgctxt "#32001" +msgid "Original" +msgstr "Original" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Mbps 1080p" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Mbps 1080p" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Mbps 1080p" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Mbps 1080p" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Mbps 720p" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Mbps 720p" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Mbps 720p" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Mbps 480p" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 kbps" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 kbps" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 kbps" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 kbps" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 kbps" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "Calidad Local" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Calidad Remoto" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "Calidad Online" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Formato transcodificación" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Log Depuración" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Permitir Reproducción en Directo" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Permitir Stream en Directo" + +msgctxt "#32027" +msgid "Force" +msgstr "Forzar" + +msgctxt "#32028" +msgid "Always" +msgstr "Siempre" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "Solo formatos de imagen" + +msgctxt "#32030" +msgid "Auto" +msgstr "Auto" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "Subtítulos (Solo Reproducción Directo)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Permetir conexiones inseguras" + +msgctxt "#32033" +msgid "Never" +msgstr "Nunca" + +msgctxt "#32034" +msgid "On Same network" +msgstr "En la misma red" + +msgctxt "#32035" +msgid "Always" +msgstr "Siempre" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "Permitir 4K" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "Permetir HEVC (h265)" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Accesso automatico" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Post Play Auto Play" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Activar la descarga de subtítulos" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Activar la descarga de subtítulos" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Detección del Servidor (GDM)" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Arrancar Plex al iniciar Kodi" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "IP Conexión 1" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "Puerto Conexión 1" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "IP Conexión 2" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "Puerto conexión 2" + +msgctxt "#32048" +msgid "Audio" +msgstr "Audio" + +msgctxt "#32049" +msgid "Advanced" +msgstr "Avanzar" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Servidores manuales" + +msgctxt "#32051" +msgid "Privacy" +msgstr "Privacidad" + +msgctxt "#32052" +msgid "About" +msgstr "Acerca de" + +msgctxt "#32053" +msgid "Video" +msgstr "Video" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "Versión Add-on" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Versión Kodi" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Resolución de Pantalla" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Versión del Servidor" + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "Omitir selección de usuario y PIN al iniciar." + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Si se activa, cuando acabe la reproducción y 'Siguiente Capítulo' esté disponible, se reproducirá automáticamente a los 15 segundos." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Activa esto si se puede reproducir contenido 4K. Desactívalo para forzar la trasncodificación." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Activa esto si se puede reproducir HEVC/h265. Desactiva para forzar la trascodificación." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Conectar a servidores con conexiones no seguras.[CR][CR]* [B]Nunca[/B]: Nunca conectarse a un servidor de forma no segura[CR]* [B]En la misma red[/B]: Permitir para la misma red[CR]* [B]Siempre[/B]: Permitir para todas las conexiones" + +msgctxt "#32201" +msgid "Trailer" +msgstr "Tráiler" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Escenas eliminadas" + +msgctxt "#32203" +msgid "Interview" +msgstr "Entrevista" + +msgctxt "#32204" +msgid "Music Video" +msgstr "Video musical" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "Detrás de las escenas" + +msgctxt "#32206" +msgid "Scene" +msgstr "Escena" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Videos de música en vivo" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Videos de letras musicales" + +msgctxt "#32209" +msgid "Concert" +msgstr "Conciertos" + +msgctxt "#32210" +msgid "Featurette" +msgstr "Featurette" + +msgctxt "#32211" +msgid "Short" +msgstr "Cortos" + +msgctxt "#32212" +msgid "Other" +msgstr "Otros" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "Ir a Album" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Ir a Artistas" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Ir a {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "Temporada" + +msgctxt "#32304" +msgid "Episode" +msgstr "Capítulo" + +msgctxt "#32305" +msgid "Extras" +msgstr "Extras" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "Series relacionadas" + +msgctxt "#32307" +msgid "More" +msgstr "Más" + +msgctxt "#32308" +msgid "Available" +msgstr "Disponible" + +msgctxt "#32309" +msgid "None" +msgstr "Ninguno" + +msgctxt "#32310" +msgid "S" +msgstr "S" + +msgctxt "#32311" +msgid "E" +msgstr "E" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "No disponible" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Este elemento no está disponible actualmente" + +msgctxt "#32314" +msgid "In Progress" +msgstr "En curso" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "¿Continuar reproducción?" + +msgctxt "#32316" +msgid "Resume" +msgstr "Continuar" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Reproducir desde el principio" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Marcar como no visto" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "Marcar como visto" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Marca la Temporada como no vista" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Marcar la Temporada como vista" + +msgctxt "#32322" +msgid "Delete" +msgstr "Borrar" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "Ir a la Serie" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Ir a {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "Reproducir el siguiente" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "¿Borrarlo de verdad?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "¿Seguro que quieres borrar este medio?" + +msgctxt "#32328" +msgid "Yes" +msgstr "Sí" + +msgctxt "#32329" +msgid "No" +msgstr "No" + +msgctxt "#32330" +msgid "Message" +msgstr "Mensaje" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "Hubo un problema al intentar borrar este medio." + +msgctxt "#32332" +msgid "Home" +msgstr "Inicio" + +msgctxt "#32333" +msgid "Playlists" +msgstr "Listas de reproducción" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Salir realmente" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "¿Listo para salir de Plex?" + +msgctxt "#32336" +msgid "Exit" +msgstr "Salir" + +msgctxt "#32337" +msgid "Cancel" +msgstr "Cancelar" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Ningún servidor encontrado" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "Servidor no accessible" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "Tests de conexión en curso. Espere por favor." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "Servidor no accessible. Por favor, comprueba la conexión de tu servidor." + +msgctxt "#32342" +msgid "Switch User" +msgstr "Cambiar usuario" + +msgctxt "#32343" +msgid "Settings" +msgstr "Configuración" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "Desconectar" + +msgctxt "#32345" +msgid "All" +msgstr "Todo" + +msgctxt "#32346" +msgid "By Name" +msgstr "Por Nombre" + +msgctxt "#32347" +msgid "Artists" +msgstr "Artistas" + +msgctxt "#32348" +msgid "movies" +msgstr "películas" + +msgctxt "#32349" +msgid "photos" +msgstr "fotos" + +msgctxt "#32350" +msgid "Shows" +msgstr "Series" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "Por fecha" + +msgctxt "#32352" +msgid "Date Added" +msgstr "Fecha" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "Por fecha de estreno" + +msgctxt "#32354" +msgid "Release Date" +msgstr "Fecha de estreno" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "Por fecha de visionado" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Fecha de visionado" + +msgctxt "#32357" +msgid "By Name" +msgstr "Por nombre" + +msgctxt "#32358" +msgid "Name" +msgstr "Nombre" + +msgctxt "#32359" +msgid "By Rating" +msgstr "Por valoración" + +msgctxt "#32360" +msgid "Rating" +msgstr "Valoración" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "Por Resolución" + +msgctxt "#32362" +msgid "Resolution" +msgstr "Resolución" + +msgctxt "#32363" +msgid "By Duration" +msgstr "Por Duración" + +msgctxt "#32364" +msgid "Duration" +msgstr "Duración" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "Por primera emisión" + +msgctxt "#32366" +msgid "First Aired" +msgstr "Primera emisión" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "Por No Vistos" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "Non visto" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "Por Fecha de Reproducción" + +msgctxt "#32370" +msgid "Date Played" +msgstr "Fecha de Reproducción" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "Por Número de Reproducciones" + +msgctxt "#32372" +msgid "Play Count" +msgstr "Número de Reproducciones" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "Por Fecha de Captura" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "Fecha de Captura" + +msgctxt "#32375" +msgid "No filters available" +msgstr "Sin filtros disponbles" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Quitar filtros" + +msgctxt "#32377" +msgid "Year" +msgstr "Año" + +msgctxt "#32378" +msgid "Decade" +msgstr "Década" + +msgctxt "#32379" +msgid "Genre" +msgstr "Género" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "Clasificación por Edad" + +msgctxt "#32381" +msgid "Network" +msgstr "Red" + +msgctxt "#32382" +msgid "Collection" +msgstr "Colección" + +msgctxt "#32383" +msgid "Director" +msgstr "Dirección" + +msgctxt "#32384" +msgid "Actor" +msgstr "Actores" + +msgctxt "#32385" +msgid "Country" +msgstr "País" + +msgctxt "#32386" +msgid "Studio" +msgstr "Estudio" + +msgctxt "#32387" +msgid "Labels" +msgstr "Etiquetas" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "Marca de Cámara" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "Modelo de Cámera" + +msgctxt "#32390" +msgid "Aperture" +msgstr "Apertura" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Velocidad de apertura" + +msgctxt "#32392" +msgid "Lens" +msgstr "Lente" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "Series de TV" + +msgctxt "#32394" +msgid "Music" +msgstr "Música" + +msgctxt "#32395" +msgid "Audio" +msgstr "Audio" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "Subtítulos" + +msgctxt "#32397" +msgid "Quality" +msgstr "Calidad" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Configuración de Video de Kodi" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Configuración de Audio de Kodi" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "Ir a Temporada" + +msgctxt "#32401" +msgid "Directors" +msgstr "Dirección" + +msgctxt "#32402" +msgid "Writer" +msgstr "Autor" + +msgctxt "#32403" +msgid "Writers" +msgstr "Guionistas" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "Películas relacionadas" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Descargar Subtítulos" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Retardo en Subtítulos" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Siguiente Subtítulo" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Desactivar Subtítulos" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Activar Subtítulos" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "Versión de la Plataforma" + +msgctxt "#32411" +msgid "Unknown" +msgstr "Desconocido" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Modificar o Borrar" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "¿Editar la direeción IP o borrar la configuración?" + +msgctxt "#32414" +msgid "Clear" +msgstr "Borrar" + +msgctxt "#32415" +msgid "Edit" +msgstr "Modificar" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "Introduce la Dirección IP" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Introduce el Puerto" + +msgctxt "#32418" +msgid "Creator" +msgstr "Creador" + +msgctxt "#32419" +msgid "Cast" +msgstr "Elenco" + +msgctxt "#32420" +msgid "Disc" +msgstr "Disco" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "Desconectar" + +msgctxt "#32422" +msgid "Exit" +msgstr "Salir" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "Apagar" + +msgctxt "#32424" +msgid "Suspend" +msgstr "Suspender" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "Hibernar" + +msgctxt "#32426" +msgid "Reboot" +msgstr "Reiniciar" + +msgctxt "#32427" +msgid "Failed" +msgstr "Fallo" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "¡El acceso falló!" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Continuar desde {0}" + +msgctxt "#32430" +msgid "Discovery" +msgstr "Descubrimiento" + +msgctxt "#32431" +msgid "Search" +msgstr "Búsqueda" + +msgctxt "#32432" +msgid "Space" +msgstr "Espacio" + +msgctxt "#32433" +msgid "Clear" +msgstr "Borrar" + +msgctxt "#32434" +msgid "Searching..." +msgstr "Buscando..." + +msgctxt "#32435" +msgid "No Results" +msgstr "Sin resultados" + +msgctxt "#32436" +msgid "Paused" +msgstr "En Pausa" + +msgctxt "#32437" +msgid "Welcome" +msgstr "Bienvenido" + +msgctxt "#32438" +msgid "Previous" +msgstr "Anterior" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "Reproducir el Siguiente" + +msgctxt "#32440" +msgid "On Deck" +msgstr "En Portada" + +msgctxt "#32441" +msgid "Unknown" +msgstr "Desconocido" + +msgctxt "#32442" +msgid "Embedded" +msgstr "Integrado" + +msgctxt "#32443" +msgid "Forced" +msgstr "Forzado" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "Letras" + +msgctxt "#32445" +msgid "Mono" +msgstr "Mono" + +msgctxt "#32446" +msgid "Stereo" +msgstr "Stereo" + +msgctxt "#32447" +msgid "None" +msgstr "Ninguno" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "¡La reproducción falló!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "No pude conectarme a plex.tv[CR]Comprueba la conexión y vuelve a intentarlo." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "Elegir una Versión" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "Reproduce un Versión..." + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "No hay contenido disponible en esta bilioteca" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "Por favo, añade contenido y/o comprueba que 'Incluir en el dashboard' esté activado." + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Sin contenido disponible por este filtro" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Por favor, cambie o quite el filtro actual" + +msgctxt "#32456" +msgid "Show" +msgstr "Serie" + +msgctxt "#32457" +msgid "By Show" +msgstr "Por Serie" + +msgctxt "#32458" +msgid "Episodes" +msgstr "Capítulo" + +msgctxt "#32459" +msgid "Offline Mode" +msgstr "Modo Offline" + +msgctxt "#32460" +msgid "Sign In" +msgstr "Acceso" + +msgctxt "#32461" +msgid "Albums" +msgstr "Albums" + +msgctxt "#32462" +msgid "Artist" +msgstr "Artista" + +msgctxt "#32463" +msgid "By Artist" +msgstr "Por Artista" diff --git a/script.plexmod/resources/language/resource.language.fr_fr/strings.po b/script.plexmod/resources/language/resource.language.fr_fr/strings.po new file mode 100644 index 000000000..b4ec81eca --- /dev/null +++ b/script.plexmod/resources/language/resource.language.fr_fr/strings.po @@ -0,0 +1,953 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: 2017-05-29 16:47++0200\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" + +msgctxt "#32000" +msgid "Main" +msgstr "Main" + +msgctxt "#32001" +msgid "Original" +msgstr "Original" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Mbps 1080p" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Mbps 1080p" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Mbps 1080p" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Mbps 1080p" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Mbps 720p" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Mbps 720p" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Mbps 720p" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Mbps 480p" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 kbps" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 kbps" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 kbps" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 kbps" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 kbps" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "Qualité locale" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Qualité distante" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "Qualité online" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Format transcodage" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Debug Logging" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Autoriser Direct Play" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Autoriser Direct Stream" + +msgctxt "#32027" +msgid "Force" +msgstr "Force" + +msgctxt "#32028" +msgid "Always" +msgstr "Toujours" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "Seulement formats d'image" + +msgctxt "#32030" +msgid "Auto" +msgstr "Auto" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "Incruster sous-titres (pour Direct Play)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Autoriser connexions non sécurisées" + +msgctxt "#32033" +msgid "Never" +msgstr "Jamais" + +msgctxt "#32034" +msgid "On Same network" +msgstr "Sur le même réseau" + +msgctxt "#32035" +msgid "Always" +msgstr "Toujours" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "Autoriser 4K" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "Autoriser HEVC (h265)" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Connexion automatique" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Post Play Auto Play" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Activer le téléchargement des sous-titres" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Activer le téléchargement des sous-titres" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Server Discovery (GDM)" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Démarrer Plex au démarrage de Kodi" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "Connexion 1 IP" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "Connexion 1 Port" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "Connexion 2 IP" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "Connexion 2 Port" + +msgctxt "#32048" +msgid "Audio" +msgstr "Audio" + +msgctxt "#32049" +msgid "Advanced" +msgstr "Avancée" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Serveurs manuels" + +msgctxt "#32051" +msgid "Privacy" +msgstr "Privacy" + +msgctxt "#32052" +msgid "About" +msgstr "A propos" + +msgctxt "#32053" +msgid "Video" +msgstr "Video" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "Version de l'addon" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Version de kodi" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Résolution de l'écran" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Version actuelle du serveur" + + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "Passer l'écran de sélection de l'utilisateur et du PIN au démarrage" + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Si actif, à la fin de la lecture, les éléments suivants seront joués après un délai de 15s." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Activer cette option si votre hardware est capable de lire la 4K. Désactiver pour forcer le transcodage." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Activer cette option si votre hardware est capable de décoder l'HEVC/h265. Désactiver pour forcer le transcodage." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Pour se connecter à un serveur via une connexion non sécurisée.[CR][CR]* [B]Attention[/B]: Ne jamais se connecter à un serveur via une connexion son sécurisée[CR]* [B]Sur le même réseau[/B]: Autoriser si même réseau[CR]* [B]Toujours[/B]: Autoriser même réseau et connexions distantes" + + +msgctxt "#32201" +msgid "Trailer" +msgstr "Bande-annonce" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Supprimer scène" + +msgctxt "#32203" +msgid "Interview" +msgstr "Interview" + +msgctxt "#32204" +msgid "Music Video" +msgstr "Vidéos Musiques" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "Dans les coulisses" + +msgctxt "#32206" +msgid "Scene" +msgstr "Scene" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Vidéos Musiques Live" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Paroles vidéos musiques" + +msgctxt "#32209" +msgid "Concert" +msgstr "Concert" + +msgctxt "#32210" +msgid "Featurette" +msgstr "Featurette" + +msgctxt "#32211" +msgid "Short" +msgstr "Courte" + +msgctxt "#32212" +msgid "Other" +msgstr "Autres" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "Voir l'album" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Voir l'artiste" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Aller à {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "Saison" + +msgctxt "#32304" +msgid "Episode" +msgstr "Episode" + +msgctxt "#32305" +msgid "Extras" +msgstr "Extras" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "Liens" + +msgctxt "#32307" +msgid "More" +msgstr "Plus" + +msgctxt "#32308" +msgid "Available" +msgstr "Disponible" + +msgctxt "#32309" +msgid "None" +msgstr "Aucun" + +msgctxt "#32310" +msgid "S" +msgstr "S" + +msgctxt "#32311" +msgid "E" +msgstr "E" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "Non disponible" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Cet élément n'est actuellement pas disponible." + +msgctxt "#32314" +msgid "In Progress" +msgstr "En cours" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "Poursuivre la lecture ?" + +msgctxt "#32316" +msgid "Resume" +msgstr "Resume" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Lire depuis le début" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Marquer comme non vu" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "Marquer comme vu" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Marquer saison comme non vue" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Marquer saison comme vue" + +msgctxt "#32322" +msgid "Delete" +msgstr "Supprimer" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "Voir le Show" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Aller à {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "Lire le media suivant" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "Supprimer?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "Etes-vous sûr de vouloir supprimer ce media ?" + +msgctxt "#32328" +msgid "Yes" +msgstr "Oui" + +msgctxt "#32329" +msgid "No" +msgstr "Non" + +msgctxt "#32330" +msgid "Message" +msgstr "Message" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "Erreur : impossible de supprimer le media" + +msgctxt "#32332" +msgid "Home" +msgstr "Home" + +msgctxt "#32333" +msgid "Playlists" +msgstr "Playlists" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Quitter" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "Souhaitez-vous quitter Plex ?" + +msgctxt "#32336" +msgid "Exit" +msgstr "Quitter" + +msgctxt "#32337" +msgid "Cancel" +msgstr "Annuler" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Aucun serveur trouvé" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "Serveur non accessible" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "Tests de connexion en cours. Merci de patienter." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "Serveur non joignable. Merci de vérifier l'accessibilité du serveur." + +msgctxt "#32342" +msgid "Switch User" +msgstr "Changer d'utilisateur" + +msgctxt "#32343" +msgid "Settings" +msgstr "Paramètres" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "Déconnexion" + +msgctxt "#32345" +msgid "All" +msgstr "Tous" + +msgctxt "#32346" +msgid "By Name" +msgstr "Par Nom" + +msgctxt "#32347" +msgid "Artists" +msgstr "Artites" + +msgctxt "#32348" +msgid "movies" +msgstr "films" + +msgctxt "#32349" +msgid "photos" +msgstr "photos" + +msgctxt "#32350" +msgid "Shows" +msgstr "Séries" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "Par date d'ajout" + +msgctxt "#32352" +msgid "Date Added" +msgstr "Date d'ajout" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "Par date de sortie" + +msgctxt "#32354" +msgid "Release Date" +msgstr "Date de sortie" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "Par date de visionnage" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Date du visionnage" + +msgctxt "#32357" +msgid "By Name" +msgstr "Par nom" + +msgctxt "#32358" +msgid "Name" +msgstr "Nom" + +msgctxt "#32359" +msgid "By Rating" +msgstr "Par note" + +msgctxt "#32360" +msgid "Rating" +msgstr "Note" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "Par qualité" + +msgctxt "#32362" +msgid "Resolution" +msgstr "Qualité" + +msgctxt "#32363" +msgid "By Duration" +msgstr "Par durée" + +msgctxt "#32364" +msgid "Duration" +msgstr "Durée" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "Par première diffusion" + +msgctxt "#32366" +msgid "First Aired" +msgstr "Première diffusion" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "Par non vus" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "Non vus" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "Par date de lecture" + +msgctxt "#32370" +msgid "Date Played" +msgstr "Date de lecture" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "Par nombre de lectures" + +msgctxt "#32372" +msgid "Play Count" +msgstr "Nombre de lectures" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "Par date de prise de vue" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "Date de prise de vue" + +msgctxt "#32375" +msgid "No filters available" +msgstr "Aucuns filtres disponibles" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Effacer filtres" + +msgctxt "#32377" +msgid "Year" +msgstr "Année" + +msgctxt "#32378" +msgid "Decade" +msgstr "Décennie" + +msgctxt "#32379" +msgid "Genre" +msgstr "Genre" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "Note du contenu" + +msgctxt "#32381" +msgid "Network" +msgstr "Réseau" + +msgctxt "#32382" +msgid "Collection" +msgstr "Collection" + +msgctxt "#32383" +msgid "Director" +msgstr "Réalisateur" + +msgctxt "#32384" +msgid "Actor" +msgstr "Acteur" + +msgctxt "#32385" +msgid "Country" +msgstr "Pays" + +msgctxt "#32386" +msgid "Studio" +msgstr "Studio" + +msgctxt "#32387" +msgid "Labels" +msgstr "Labels" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "Marque appareil photo" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "Modèle caméra" + +msgctxt "#32390" +msgid "Aperture" +msgstr "Aperture" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Vitesse d'obturation" + +msgctxt "#32392" +msgid "Lens" +msgstr "Lentille" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "Séries TV" + +msgctxt "#32394" +msgid "Music" +msgstr "Musiques" + +msgctxt "#32395" +msgid "Audio" +msgstr "Audio" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "Sous-titres" + +msgctxt "#32397" +msgid "Quality" +msgstr "Qualité" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Paramètres vidéo Kodi" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Paramètres audio Kodi" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "Aller à la saison" + +msgctxt "#32401" +msgid "Directors" +msgstr "Réalisateurs" + +msgctxt "#32402" +msgid "Writer" +msgstr "Auteur" + +msgctxt "#32403" +msgid "Writers" +msgstr "Auteurs" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "Films liés" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Télécharger sous-titres" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Délai sous-titres" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Sous-titres suivants" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Désactiver sous-titres" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Activer sous-titres" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "Version de la plateforme" + +msgctxt "#32411" +msgid "Unknown" +msgstr "Inconnu" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Editer ou Effacer" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "Editer adresse IP ou effacer les paramètres actuels ?" + +msgctxt "#32414" +msgid "Clear" +msgstr "Effacer" + +msgctxt "#32415" +msgid "Edit" +msgstr "Editer" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "Saisir adresse IP" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Saisir le port" + +msgctxt "#32418" +msgid "Creator" +msgstr "Creator" + +msgctxt "#32419" +msgid "Cast" +msgstr "Casting" + +msgctxt "#32420" +msgid "Disc" +msgstr "Disc" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "Déconnexion" + +msgctxt "#32422" +msgid "Exit" +msgstr "Quitter" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "Eteindre" + +msgctxt "#32424" +msgid "Suspend" +msgstr "Veille" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "Veille prolongée" + +msgctxt "#32426" +msgid "Reboot" +msgstr "Redémarrer" + +msgctxt "#32427" +msgid "Failed" +msgstr "Echec" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "Impossible de se connecter" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Reprendre à {0}" + +msgctxt "#32430" +msgid "Discovery" +msgstr "Recherche" + +msgctxt "#32431" +msgid "Search" +msgstr "Recherche" + +msgctxt "#32432" +msgid "Space" +msgstr "Espace" + +msgctxt "#32433" +msgid "Clear" +msgstr "Effacer" + +msgctxt "#32434" +msgid "Searching..." +msgstr "Recherche en cours..." + +msgctxt "#32435" +msgid "No Results" +msgstr "Aucun résultat" + +msgctxt "#32436" +msgid "Paused" +msgstr "Pause" + +msgctxt "#32437" +msgid "Welcome" +msgstr "Bienvenue" + +msgctxt "#32438" +msgid "Previous" +msgstr "Précédent" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "A regarder ensuite" + +msgctxt "#32440" +msgid "On Deck" +msgstr "Découvrir" + +msgctxt "#32441" +msgid "Unknown" +msgstr "Inconnu" + +msgctxt "#32442" +msgid "Embedded" +msgstr "Intégré" + +msgctxt "#32443" +msgid "Forced" +msgstr "Forcé" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "Parole" + +msgctxt "#32445" +msgid "Mono" +msgstr "Mono" + +msgctxt "#32446" +msgid "Stereo" +msgstr "Stereo" + +msgctxt "#32447" +msgid "None" +msgstr "Aucun" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "Echec de lecture!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "Impossible de se connecter à plex.tv[CR]Vérifiez votre connexion et réessayez." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "Choisir une version" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "Lire version..." + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "" + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Aucun contenu disponible dans cette librairie" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Merci de modifier ou supprimer le filtre courant" + +msgctxt "#32456" +msgid "Show" +msgstr "" + +msgctxt "#32457" +msgid "By Show" +msgstr "" + +msgctxt "#32458" +msgid "Episodes" +msgstr "" + +msgctxt "#32459" +msgid "Offline Mode" +msgstr "Mode Hors-Ligne" + +msgctxt "#32460" +msgid "Sign In" +msgstr "Se connecter" + +msgctxt "#32461" +msgid "Albums" +msgstr "" + +msgctxt "#32462" +msgid "Artist" +msgstr "" + +msgctxt "#32463" +msgid "By Artist" +msgstr "" + diff --git a/script.plexmod/resources/language/resource.language.hu_hu/strings.po b/script.plexmod/resources/language/resource.language.hu_hu/strings.po new file mode 100644 index 000000000..18410177c --- /dev/null +++ b/script.plexmod/resources/language/resource.language.hu_hu/strings.po @@ -0,0 +1,928 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: 2017-04-21 19:23+0100\n" +"Last-Translator: Norbert Suto \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: hu\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" + +msgctxt "#32000" +msgid "Main" +msgstr "Főoldal" + +msgctxt "#32001" +msgid "Original" +msgstr "Eredeti" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Mbps 1080p" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Mbps 1080p" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Mbps 1080p" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Mbps 1080p" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Mbps 720p" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Mbps 720p" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Mbps 720p" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Mbps 480p" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 kbps" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 kbps" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 kbps" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 kbps" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 kbps" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "Helyi minőség" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Távoli minőség" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "Online minőség" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Átkódolási formátum" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Hibakeresési naplózás" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Közvetlen lejátszás engedélyezése" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Közvetlen adatfolyam engedélyezése" + +msgctxt "#32027" +msgid "Force" +msgstr "Kényszerítés" + +msgctxt "#32028" +msgid "Always" +msgstr "Mindig" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "Csak képformátumok esetében" + +msgctxt "#32030" +msgid "Auto" +msgstr "Automatikus" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "Feliratok égetése (Csak közvetlen lejátszás esetén)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Nem biztonságos kapcsolatok engedélyezése" + +msgctxt "#32033" +msgid "Never" +msgstr "Soha" + +msgctxt "#32034" +msgid "On Same network" +msgstr "Ugyanazon a hálózaton" + +msgctxt "#32035" +msgid "Always" +msgstr "Mindig" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "4K engedélyezése" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "HEVC (h265) engedélyezése" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Automatikus bejelentkezés" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Post Play Auto Play" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Felirat letöltés engedélyezése" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Felirat letöltés engedélyezése" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Szerver felderítés" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Indítsa el a Plexet a Kodi indításakor" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "1. Kapcsolat IP" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "1. Kapcsolat Port" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "2. Kapcsolat IP" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "2. Kapcsolat Port" + +msgctxt "#32048" +msgid "Audio" +msgstr "Audió" + +msgctxt "#32049" +msgid "Advanced" +msgstr "Fejlett beállítások" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Kézi szerverek" + +msgctxt "#32051" +msgid "Privacy" +msgstr "Adatvédelem" + +msgctxt "#32052" +msgid "About" +msgstr "Rólunk" + +msgctxt "#32053" +msgid "Video" +msgstr "Videó" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "Addon verzió" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Kodi verzió" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Képernyőfelbontás" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Jelenlegi szerver verzió" + + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "Felhasználó kiválasztás és a PIN kód megadás átugrása indításkor." + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Ha engedélyezi, akkor a lejátszás befejezése után, amennyiben rendelkezésre áll egy \"következő\" elem, az 15 másodperces késleltetés után automatikusan lejátszásra kerül." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Engedélyezze ezt, ha a hardver képes a 4K lejátszásra. Kapcsolja ki, amennyiben kényszeríteni szeretné az átkódolást." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Engedélyezze ezt, ha a hardver képes a HEVC/h265 lejátszásra. Kapcsolja ki, amennyiben kényszeríteni szeretné az átkódolást." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Mikor kapcsolódhat nem biztonságosan a szerverekhez.[CR][CR]* [B]Soha[/B]: Soha ne kapcsolódjon[CR]* [B]Azonos hálózat[/B]: Azonos hálózat esetén engedélyezze[CR]* [B]Mindig[/B]: Engedélyezze helyi és távoli kapcsolat esetén is" + + +msgctxt "#32201" +msgid "Trailer" +msgstr "Előzetes" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Törölt jelenet" + +msgctxt "#32203" +msgid "Interview" +msgstr "Interjú" + +msgctxt "#32204" +msgid "Music Video" +msgstr "Videóklip" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "A kulisszák mögött" + +msgctxt "#32206" +msgid "Scene" +msgstr "Jelenet" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Élő zenei videó" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Zenei videó dalszöveggel" + +msgctxt "#32209" +msgid "Concert" +msgstr "Koncert" + +msgctxt "#32210" +msgid "Featurette" +msgstr "Kísérő film" + +msgctxt "#32211" +msgid "Short" +msgstr "Rövidfilm" + +msgctxt "#32212" +msgid "Other" +msgstr "Egyéb" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "Ugrás az Albumra" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Ugrás az előadóra" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Ugrás ide {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "Évad" + +msgctxt "#32304" +msgid "Episode" +msgstr "Epizód" + +msgctxt "#32305" +msgid "Extras" +msgstr "Extrák" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "Kapcsolódó műsorok" + +msgctxt "#32307" +msgid "More" +msgstr "Egyéb" + +msgctxt "#32308" +msgid "Available" +msgstr "Elérhető" + +msgctxt "#32309" +msgid "None" +msgstr "Nincs" + +msgctxt "#32310" +msgid "S" +msgstr "" + +msgctxt "#32311" +msgid "E" +msgstr "" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "Nem elérhető" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Ez az elem jelenleg nem érhető el." + +msgctxt "#32314" +msgid "In Progress" +msgstr "Folyamatban" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "Folytatja a lejátszást?" + +msgctxt "#32316" +msgid "Resume" +msgstr "Folytat" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Lejátszás az elejétől" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Nem nézettnek jelölés" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "Nézettnek jelölés" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Az évad nem nézettnek jelölése" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Az évad nézettnek jelölése" + +msgctxt "#32322" +msgid "Delete" +msgstr "Törlés" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "Ugrás a műsorra" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Ugrás {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "Következő lejátszása" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "Tényleg törli?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "Biztos benne, hogy törli az alábbi tartalmat?" + +msgctxt "#32328" +msgid "Yes" +msgstr "Igen" + +msgctxt "#32329" +msgid "No" +msgstr "Nem" + +msgctxt "#32330" +msgid "Message" +msgstr "Üzenet" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "Probléma történt a tartalom törlése közben." + +msgctxt "#32332" +msgid "Home" +msgstr "Kezdőoldal" + +msgctxt "#32333" +msgid "Playlists" +msgstr "Lejátszási listák" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Kilépés megerősítése" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "Készen áll arra, hogy kilépjen a Plexből?" + +msgctxt "#32336" +msgid "Exit" +msgstr "Kilépés" + +msgctxt "#32337" +msgid "Cancel" +msgstr "Mégse" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Nem található szerver" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "A szerver nem elérhető" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "A kapcsolat tesztelése folyamatban. Kérem várjon." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "A szerver nem elérhető. Kérem jelentkezzen be a szerverébe és ellenőrizze a hálózati kapcsolatot." + +msgctxt "#32342" +msgid "Switch User" +msgstr "Felhasználóváltás" + +msgctxt "#32343" +msgid "Settings" +msgstr "Beállítások" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "Kijelentkezés" + +msgctxt "#32345" +msgid "All" +msgstr "Összes" + +msgctxt "#32346" +msgid "By Name" +msgstr "Név szerint" + +msgctxt "#32347" +msgid "artists" +msgstr "előadók" + +msgctxt "#32348" +msgid "movies" +msgstr "filmek" + +msgctxt "#32349" +msgid "photos" +msgstr "fényképek" + +msgctxt "#32350" +msgid "shows" +msgstr "műsorok" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "Hozzáadás dátuma szerint" + +msgctxt "#32352" +msgid "Date Added" +msgstr "Dátum szerint" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "Megjelenés dátuma szerint" + +msgctxt "#32354" +msgid "Release Date" +msgstr "Megjelenés dátuma" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "Megtekintés dátuma szerint" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Megtekintés dátuma" + +msgctxt "#32357" +msgid "By Name" +msgstr "Név szerint" + +msgctxt "#32358" +msgid "Name" +msgstr "Név" + +msgctxt "#32359" +msgid "By Rating" +msgstr "Értékelés szerint" + +msgctxt "#32360" +msgid "Rating" +msgstr "Értékelés" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "Felbontás szerint" + +msgctxt "#32362" +msgid "Resolution" +msgstr "Felbontás" + +msgctxt "#32363" +msgid "By Duration" +msgstr "Időtartam szerint" + +msgctxt "#32364" +msgid "Duration" +msgstr "Időtartam" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "Első sugárzás dátuma szerint" + +msgctxt "#32366" +msgid "First Aired" +msgstr "Első sugárzás dátuma" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "Nem megtekintettek szerint" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "Nem megtekintettek" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "Lejátszás dátuma szerint" + +msgctxt "#32370" +msgid "Date Played" +msgstr "Lejátszás dátuma" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "Lájátszások száma szerint" + +msgctxt "#32372" +msgid "Play Count" +msgstr "Lejátszások száma" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "Készítés dátuma szerint" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "Készítés dátuma" + +msgctxt "#32375" +msgid "No filters available" +msgstr "Nincs elérhető szűrő" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Szűrők eltávolítása" + +msgctxt "#32377" +msgid "Year" +msgstr "Év" + +msgctxt "#32378" +msgid "Decade" +msgstr "Évtized" + +msgctxt "#32379" +msgid "Genre" +msgstr "Műfaj" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "Értékelés" + +msgctxt "#32381" +msgid "Network" +msgstr "Csatorna" + +msgctxt "#32382" +msgid "Collection" +msgstr "Nyűjtemény" + +msgctxt "#32383" +msgid "Director" +msgstr "Rendező" + +msgctxt "#32384" +msgid "Actor" +msgstr "Színész" + +msgctxt "#32385" +msgid "Country" +msgstr "Ország" + +msgctxt "#32386" +msgid "Studio" +msgstr "Stúdió" + +msgctxt "#32387" +msgid "Labels" +msgstr "Cimkék" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "Fényképezőgép gyártmánya" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "Fényképezőgép Modell" + +msgctxt "#32390" +msgid "Aperture" +msgstr "Rekesz" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Zár sebesség" + +msgctxt "#32392" +msgid "Lens" +msgstr "Lencsék" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "Sorozatok" + +msgctxt "#32394" +msgid "Music" +msgstr "Zene" + +msgctxt "#32395" +msgid "Audio" +msgstr "Audió" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "Feliratok" + +msgctxt "#32397" +msgid "Quality" +msgstr "Minőség" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Kodi videó beállitások" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Kodi audió beállitások" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "Ugrás az évadra" + +msgctxt "#32401" +msgid "Directors" +msgstr "Rendezők" + +msgctxt "#32402" +msgid "Writer" +msgstr "Szerző" + +msgctxt "#32403" +msgid "Writers" +msgstr "Szerzők" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "Kapcsolódó filmek" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Feliratok letöltése" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Felirat késleltetés" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Következő felirat" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Feliratok letiltása" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Feliratok engedélyezése" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "Platform verzió" + +msgctxt "#32411" +msgid "Unknown" +msgstr "Ismeretlen" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Szerkesztés vagy törlés" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "Módosíthatja az IP-címet, vagy törli az aktuális beállítást?" + +msgctxt "#32414" +msgid "Clear" +msgstr "Törlés" + +msgctxt "#32415" +msgid "Edit" +msgstr "Módositás" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "Adja meg az IP-címet" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Adja meg a port számát" + +msgctxt "#32418" +msgid "Creator" +msgstr "Alkotó" + +msgctxt "#32419" +msgid "Cast" +msgstr "Szereplők" + +msgctxt "#32420" +msgid "Disc" +msgstr "Lemez" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "Kijelentkezés" + +msgctxt "#32422" +msgid "Exit" +msgstr "Kilépés" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "Leállítás" + +msgctxt "#32424" +msgid "Suspend" +msgstr "Felfüggesztés" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "Hibernálás" + +msgctxt "#32426" +msgid "Reboot" +msgstr "Újraindítás" + +msgctxt "#32427" +msgid "Failed" +msgstr "Sikertelen" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "Bejelentkezés sikertelen" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Folytatás innen {0}" + +msgctxt "#32430" +msgid "Discovery" +msgstr "Felfedezés" + +msgctxt "#32431" +msgid "Search" +msgstr "Keresés" + +msgctxt "#32432" +msgid "Space" +msgstr "Space" + +msgctxt "#32433" +msgid "Clear" +msgstr "Törlés mind" + +msgctxt "#32434" +msgid "Searching..." +msgstr "Keresés..." + +msgctxt "#32435" +msgid "No Results" +msgstr "Nincs találat" + +msgctxt "#32436" +msgid "Paused" +msgstr "Szünetelve" + +msgctxt "#32437" +msgid "Welcome" +msgstr "Üdvözöljük" + +msgctxt "#32438" +msgid "Previous" +msgstr "Előző" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "Következő lejátszása" + +msgctxt "#32440" +msgid "On Deck" +msgstr "Jelenlegi" + +msgctxt "#32441" +msgid "Unknown" +msgstr "Ismeretlen" + +msgctxt "#32442" +msgid "Embedded" +msgstr "Beágyazott" + +msgctxt "#32443" +msgid "Forced" +msgstr "Kényszerített" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "Dalszöveg" + +msgctxt "#32445" +msgid "Mono" +msgstr "Monó" + +msgctxt "#32446" +msgid "Stereo" +msgstr "Sztereó" + +msgctxt "#32447" +msgid "None" +msgstr "Nincs" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "A lejátszás sikertelen!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "Nem lehet csatlakozni a plex.tv-hez[CR]Ellenőrizze az internetkapcsolatot, és próbálja újra." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "Verzió választása" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "Verzió lejátszása..." + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "Nincs elérhető tartalom ebben a könyvtárban" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "Kérem adjon hozzá tartalmat és/vagy ellenőrizze, hogy a 'Hozzáadás az irányítópulthoz' opció be van kapcsolva" + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Nincs elérhető tartalom ezzel a szűrővel" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Kérem változtassa meg, vagy távolítsa el a jelenlegi szűrőt" + +msgctxt "#32456" +msgid "Offline Mode" +msgstr "Offline mód" + +msgctxt "#32457" +msgid "Sign In" +msgstr "Bejelentkezés" diff --git a/script.plexmod/resources/language/resource.language.it_it/strings.po b/script.plexmod/resources/language/resource.language.it_it/strings.po new file mode 100644 index 000000000..50dee38bc --- /dev/null +++ b/script.plexmod/resources/language/resource.language.it_it/strings.po @@ -0,0 +1,951 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: 2017-05-25 10:52+0200\n" +"Language-Team: LANGUAGE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: it\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Last-Translator: \n" +"X-Generator: Poedit 2.0.2\n" + +msgctxt "#32000" +msgid "Main" +msgstr "Principale" + +msgctxt "#32001" +msgid "Original" +msgstr "Originale" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Mbps 1080p" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Mbps 1080p" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Mbps 1080p" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Mbps 1080p" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Mbps 720p" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Mbps 720p" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Mbps 720p" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Mbps 480p" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 kbps" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 kbps" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 kbps" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 kbps" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 kbps" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "Qualità Locale" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Qualità Remota" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "Qualità Online" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Formato transcodifica" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Log di Debug" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Permetti Play Diretto" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Permetti Stream Diretto" + +msgctxt "#32027" +msgid "Force" +msgstr "Forza" + +msgctxt "#32028" +msgid "Always" +msgstr "Sempre" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "Solo formati immagine" + +msgctxt "#32030" +msgid "Auto" +msgstr "Auto" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "Sottotitoli (Solo Play Diretto)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Permetti connessioni non sicure" + +msgctxt "#32033" +msgid "Never" +msgstr "Mai" + +msgctxt "#32034" +msgid "On Same network" +msgstr "Sulla stessa rete" + +msgctxt "#32035" +msgid "Always" +msgstr "Sempre" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "Permetti 4K" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "Permetti HEVC (h265)" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Accesso (Sign In) automatico" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Post Play Auto Play" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Abilita il download dei sottotitoli" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Abilita il download dei sottotitoli" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Ricerca Server (GDM)" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Esegui Plex all'avvio di Kodi" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "IP connessione 1" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "Porta connessione 1" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "IP connessione 2" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "Porta connessione 2" + +msgctxt "#32048" +msgid "Audio" +msgstr "Audio" + +msgctxt "#32049" +msgid "Advanced" +msgstr "Avanzate" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Server manuali" + +msgctxt "#32051" +msgid "Privacy" +msgstr "Privacy" + +msgctxt "#32052" +msgid "About" +msgstr "Circa" + +msgctxt "#32053" +msgid "Video" +msgstr "Video" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "Versione Addon" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Versione Kodi" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Risoluzione schermo" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Versione Server attuale" + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "All'avvio salta l'immissione del PIN e la selezione utente." + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Se abilitato, quando la riproduzione finisce e un 'Titolo Successivo' è disponibile, sarà automaticamente riprodotto dopo un ritardo di 15 secondi." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Abilita se il tuo hardware supporta la riproduzione 4K. Disabilita per forzare la trascodifica." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Abilita se il tuo hardware supporta HEVC/h265. Disabilita per forzare la trascodifica." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Quando connettersi a Server con connessioni non sicure.[CR][CR]* [B]Mai[/B]: Mai connettersi a Server non sicuri[CR]* [B]Sulla stessa Rete[/B]: Permetti se nella stessa Rete[CR]* [B]Sempre[/B]: Permetti sulla stessa Rete e su Reti remote" + +msgctxt "#32201" +msgid "Trailer" +msgstr "Trailer" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Scene cancellate" + +msgctxt "#32203" +msgid "Interview" +msgstr "Intervista" + +msgctxt "#32204" +msgid "Music Video" +msgstr "Video musicali" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "Dietro le scene" + +msgctxt "#32206" +msgid "Scene" +msgstr "Scena" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Video musicali dal vivo" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Testi video musicali" + +msgctxt "#32209" +msgid "Concert" +msgstr "Concerto" + +msgctxt "#32210" +msgid "Featurette" +msgstr "Featurette" + +msgctxt "#32211" +msgid "Short" +msgstr "Corto" + +msgctxt "#32212" +msgid "Other" +msgstr "Altro" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "Vai all'Album" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Vai all'Artista" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Vai a {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "Stagione" + +msgctxt "#32304" +msgid "Episode" +msgstr "Episodio" + +msgctxt "#32305" +msgid "Extras" +msgstr "Extras" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "Serie correlate" + +msgctxt "#32307" +msgid "More" +msgstr "Più" + +msgctxt "#32308" +msgid "Available" +msgstr "Disponibile" + +msgctxt "#32309" +msgid "None" +msgstr "Nessuno" + +msgctxt "#32310" +msgid "S" +msgstr "S" + +msgctxt "#32311" +msgid "E" +msgstr "E" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "Non disponibile" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Questo elemento non è al momento diponibile" + +msgctxt "#32314" +msgid "In Progress" +msgstr "In Corso" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "Riprendere la riproduzione?" + +msgctxt "#32316" +msgid "Resume" +msgstr "Riprendi" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Riproduci dall'inizio" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Marca come non visto" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "Marca come visto" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Marca la Stagione come non vista" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Marca la Stagione come vista" + +msgctxt "#32322" +msgid "Delete" +msgstr "Cancellato" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "Vai alla Serie" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Vai a {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "Riproduci il titolo succesivo" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "Cancellare veramente?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "Sei sucuro che vuoi davvero cancellare questo media?" + +msgctxt "#32328" +msgid "Yes" +msgstr "Sì" + +msgctxt "#32329" +msgid "No" +msgstr "No" + +msgctxt "#32330" +msgid "Message" +msgstr "Messaggio" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "C'è stato un problema nella cancellazione del media." + +msgctxt "#32332" +msgid "Home" +msgstr "Home" + +msgctxt "#32333" +msgid "Playlists" +msgstr "Playlists" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Conferma Uscita" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "Sei pronto per uscire da Plex?" + +msgctxt "#32336" +msgid "Exit" +msgstr "Esci" + +msgctxt "#32337" +msgid "Cancel" +msgstr "Cancella" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Nessun Server trovato" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "Server non accessibile" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "Test di connessione in corso. Per favore attenti." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "Server non accessibile. Per favore fai il sign in nel Server a controlla la connessione." + +msgctxt "#32342" +msgid "Switch User" +msgstr "Cambia utente" + +msgctxt "#32343" +msgid "Settings" +msgstr "Impostazioni" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "Esci (Sign Out)" + +msgctxt "#32345" +msgid "All" +msgstr "Tutto" + +msgctxt "#32346" +msgid "By Name" +msgstr "Per Nome" + +msgctxt "#32347" +msgid "Artists" +msgstr "Artisti" + +msgctxt "#32348" +msgid "movies" +msgstr "film" + +msgctxt "#32349" +msgid "photos" +msgstr "foto" + +msgctxt "#32350" +msgid "Shows" +msgstr "Serie" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "Per data di aggiunta" + +msgctxt "#32352" +msgid "Date Added" +msgstr "Data di aggiunta" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "Per data di uscita" + +msgctxt "#32354" +msgid "Release Date" +msgstr "Data di uscita" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "Per data di già visto" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Data di già visto" + +msgctxt "#32357" +msgid "By Name" +msgstr "Per nome" + +msgctxt "#32358" +msgid "Name" +msgstr "Nome" + +msgctxt "#32359" +msgid "By Rating" +msgstr "Per Valutazione" + +msgctxt "#32360" +msgid "Rating" +msgstr "Valutazione" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "Per Risoluzione" + +msgctxt "#32362" +msgid "Resolution" +msgstr "Risoluzione" + +msgctxt "#32363" +msgid "By Duration" +msgstr "Per Durata" + +msgctxt "#32364" +msgid "Duration" +msgstr "Durata" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "Per prima trasmissione" + +msgctxt "#32366" +msgid "First Aired" +msgstr "Prima trasmissione" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "Per Non visto" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "Non visto" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "Per data di riproduzione" + +msgctxt "#32370" +msgid "Date Played" +msgstr "Data di riproduzione" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "Per numero di volte riprodotto" + +msgctxt "#32372" +msgid "Play Count" +msgstr "Numero di volte riprodotto" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "Per data di ripresa" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "Data di ripresa" + +msgctxt "#32375" +msgid "No filters available" +msgstr "Nussun filtro disponibile" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Pulisci i filtri" + +msgctxt "#32377" +msgid "Year" +msgstr "Anno" + +msgctxt "#32378" +msgid "Decade" +msgstr "Decennio" + +msgctxt "#32379" +msgid "Genre" +msgstr "Genere" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "Classificazione dei contenuti" + +msgctxt "#32381" +msgid "Network" +msgstr "Rete" + +msgctxt "#32382" +msgid "Collection" +msgstr "Collezione" + +msgctxt "#32383" +msgid "Director" +msgstr "Regista" + +msgctxt "#32384" +msgid "Actor" +msgstr "Attore" + +msgctxt "#32385" +msgid "Country" +msgstr "Nazione" + +msgctxt "#32386" +msgid "Studio" +msgstr "Studio" + +msgctxt "#32387" +msgid "Labels" +msgstr "Etichette" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "Marca camera" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "Modello camera" + +msgctxt "#32390" +msgid "Aperture" +msgstr "Apertura" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Velocità di scatto" + +msgctxt "#32392" +msgid "Lens" +msgstr "Lenti" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "Serie televisive" + +msgctxt "#32394" +msgid "Music" +msgstr "Musica" + +msgctxt "#32395" +msgid "Audio" +msgstr "Audio" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "Sottotitoli" + +msgctxt "#32397" +msgid "Quality" +msgstr "Qualità" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Impostazioni Video di Kodi" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Impostazioni Audio di Kodi" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "Vai alla Stagione" + +msgctxt "#32401" +msgid "Directors" +msgstr "Registi" + +msgctxt "#32402" +msgid "Writer" +msgstr "Autore" + +msgctxt "#32403" +msgid "Writers" +msgstr "Autori" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "Film correlati" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Scarica Sottotitoli" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Ritardo Sottotitolo" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Sottotitolo successivo" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Disabilita sottotitoli" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Abilita Sottotitoli" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "Versione piattaforma" + +msgctxt "#32411" +msgid "Unknown" +msgstr "Sconosciuto" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Modifica o Cancella" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "Modificare l'indirizzo IP o cancellare le impostazioni correnti?" + +msgctxt "#32414" +msgid "Clear" +msgstr "Cancella" + +msgctxt "#32415" +msgid "Edit" +msgstr "Modifica" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "Inserisci l'indirizzo IP" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Inserisci il numero della Porta" + +msgctxt "#32418" +msgid "Creator" +msgstr "Creatore" + +msgctxt "#32419" +msgid "Cast" +msgstr "Cast" + +msgctxt "#32420" +msgid "Disc" +msgstr "Disc" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "Esci (Sign Out)" + +msgctxt "#32422" +msgid "Exit" +msgstr "Esci" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "Spegni" + +msgctxt "#32424" +msgid "Suspend" +msgstr "Sospenti" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "Iberna" + +msgctxt "#32426" +msgid "Reboot" +msgstr "Riavvia" + +msgctxt "#32427" +msgid "Failed" +msgstr "Fallito" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "Login fallita!" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Riprendi da {0}" + +msgctxt "#32430" +msgid "Discovery" +msgstr "Ricerca" + +msgctxt "#32431" +msgid "Search" +msgstr "Cerca" + +msgctxt "#32432" +msgid "Space" +msgstr "Spazio" + +msgctxt "#32433" +msgid "Clear" +msgstr "Pulisci" + +msgctxt "#32434" +msgid "Searching..." +msgstr "In ricerca..." + +msgctxt "#32435" +msgid "No Results" +msgstr "Nessun risultato" + +msgctxt "#32436" +msgid "Paused" +msgstr "In Pausa" + +msgctxt "#32437" +msgid "Welcome" +msgstr "Benvenuto" + +msgctxt "#32438" +msgid "Previous" +msgstr "Precedente" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "Titolo successivo" + +msgctxt "#32440" +msgid "On Deck" +msgstr "Scoprire" + +msgctxt "#32441" +msgid "Unknown" +msgstr "Sconosciuto" + +msgctxt "#32442" +msgid "Embedded" +msgstr "Integrato" + +msgctxt "#32443" +msgid "Forced" +msgstr "Forzato" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "Testi" + +msgctxt "#32445" +msgid "Mono" +msgstr "Mono" + +msgctxt "#32446" +msgid "Stereo" +msgstr "Stereo" + +msgctxt "#32447" +msgid "None" +msgstr "Nessuno" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "Riproduzione fallita!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "Impossibile connettersi a plex.tv[CR]Controlla la tua connessione ad Internet e riprova." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "Seleziona Versione" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "Versione in Play..." + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "Nessun contenuto disponibile in questa libreria" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "Per favore aggiungi contenuti e/o controlla che 'Includi nella dashboard' è abilitato." + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Nessun contenuto disponibile per questo filtro" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Per favore cambia o rimuovi il filtro corrente" + +msgctxt "#32456" +msgid "Show" +msgstr "Serie" + +msgctxt "#32457" +msgid "By Show" +msgstr "Per Serie" + +msgctxt "#32458" +msgid "Episodes" +msgstr "Episodi" + +msgctxt "#32459" +msgid "Offline Mode" +msgstr "Modo Offline" + +msgctxt "#32460" +msgid "Sign In" +msgstr "Accedi (Sign In)" + +msgctxt "#32461" +msgid "Albums" +msgstr "Albums" + +msgctxt "#32462" +msgid "Artist" +msgstr "Artista" + +msgctxt "#32463" +msgid "By Artist" +msgstr "Per Artista" diff --git a/script.plexmod/resources/language/resource.language.pl_pl/strings.po b/script.plexmod/resources/language/resource.language.pl_pl/strings.po new file mode 100644 index 000000000..d8b263ae0 --- /dev/null +++ b/script.plexmod/resources/language/resource.language.pl_pl/strings.po @@ -0,0 +1,984 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: 2020-06-25 12:04+0200\n" +"Last-Translator: Lukasz Zacharski \n" +"Language-Team: LANGUAGE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: pl\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" + +msgctxt "#32000" +msgid "Main" +msgstr "Główne" + +msgctxt "#32001" +msgid "Original" +msgstr "Oryginalna" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Mb/s 1080p" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Mb/s 1080p" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Mb/s 1080p" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Mb/s 1080p" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Mb/s 720p" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Mb/s 720p" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Mb/s 720p" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Mb/s 480p" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 kb/s" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 kb/s" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 kb/s" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 kb/s" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 kb/s" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "Jakość lokalna" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Jakość zdalna" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "Jakość online" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Format transkodowania" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Dziennik debugowania" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Zezwól na bezpośrednie odtwarzanie" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Zezwól na bezpośrednie strumieniowanie" + +msgctxt "#32027" +msgid "Force" +msgstr "Wymuszaj" + +msgctxt "#32028" +msgid "Always" +msgstr "Zawsze" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "Tylko formaty graficzne" + +msgctxt "#32030" +msgid "Auto" +msgstr "Automatyczne" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "Wypalaj napisy (tylko bezpośrednie odtwarzanie)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Zezwól na niezabezpieczone połączenia" + +msgctxt "#32033" +msgid "Never" +msgstr "Nigdy" + +msgctxt "#32034" +msgid "On Same network" +msgstr "W tej samej sieci" + +msgctxt "#32035" +msgid "Always" +msgstr "Zawsze" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "Zezwól na 4K" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "Zezwól na HEVC (H.265)" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Zaloguj się automatycznie" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Odtwarzaj „Następne” automatycznie" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Włącz pobieranie napisów" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Włącz pobieranie napisów" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Odnajdowanie serwera (GDM)" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Uruchom Plex przy starcie Kodi" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "IP połączenia 1" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "Port połączenia 1" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "IP połączenia 2" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "Port połączenia 2" + +msgctxt "#32048" +msgid "Audio" +msgstr "Audio" + +msgctxt "#32049" +msgid "Advanced" +msgstr "Zaawansowane" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Ręcznie dodane serwery" + +msgctxt "#32051" +msgid "Privacy" +msgstr "Prywatność" + +msgctxt "#32052" +msgid "About" +msgstr "O dodatku" + +msgctxt "#32053" +msgid "Video" +msgstr "Wideo" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "Wersja dodatku" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Wersja Kodi" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Rozdzielczość ekranu" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Obecna wersja serwera" + + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "Pomiń wybór użytkownika i kod PIN przy starcie." + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Jeśli włączone, po zakończeniu odtwarzania i dostępnej pozycji w „Następne”, zostanie ona automatycznie odtworzona po 15 sekundach." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Włącz, jeśli Twój sprzęt obsługuje odtwarzanie 4K. Wyłącz, aby wymusić transkodowanie." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Włącz, jeśli Twój sprzęt obsługuje HEVC/H.265. Wyłącz, aby wymusić transkodowanie." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Kiedy łączyć z serwerami bez zabezpieczonych połączeń.[CR][CR]* [B]Nigdy[/B]: Nigdy nie łącz w sposób niezabezpieczony[CR]* [B]W tej samej sieci[/B]: Zezwól, jeśli w tej samej sieci[CR]* [B]Zawsze[/B]: Zezwól w tej samej sieci i zdalnie" + + +msgctxt "#32201" +msgid "Trailer" +msgstr "Zwiastun" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Usunięta scena" + +msgctxt "#32203" +msgid "Interview" +msgstr "Wywiad" + +msgctxt "#32204" +msgid "Music Video" +msgstr "Teledysk" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "Za kulisami" + +msgctxt "#32206" +msgid "Scene" +msgstr "Scena" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Teledysk na żywo" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Teledysk z tekstem" + +msgctxt "#32209" +msgid "Concert" +msgstr "Koncert" + +msgctxt "#32210" +msgid "Featurette" +msgstr "Średniometrażowy" + +msgctxt "#32211" +msgid "Short" +msgstr "Krótkometrażowy" + +msgctxt "#32212" +msgid "Other" +msgstr "Inne" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "Przejdź do albumu" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Przejdź do wykonawcy" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Przejdź do {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "Sezon" + +msgctxt "#32304" +msgid "Episode" +msgstr "Odcinek" + +msgctxt "#32305" +msgid "Extras" +msgstr "Dodatki" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "Powiązane seriale" + +msgctxt "#32307" +msgid "More" +msgstr "Więcej" + +msgctxt "#32308" +msgid "Available" +msgstr "Dostępne" + +msgctxt "#32309" +msgid "None" +msgstr "Brak" + +msgctxt "#32310" +msgid "S" +msgstr "S" + +msgctxt "#32311" +msgid "E" +msgstr "O" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "Niedostępne" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Ten element jest obecnie niedostępny." + +msgctxt "#32314" +msgid "In Progress" +msgstr "W toku" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "Wznowić odtwarzanie" + +msgctxt "#32316" +msgid "Resume" +msgstr "Wznów" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Odtwórz od początku" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Oznacz jako nieodtworzone" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "Oznacz jako odtworzone" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Oznacz sezon jako nieodtworzony" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Oznacz sezon jako odtworzony" + +msgctxt "#32322" +msgid "Delete" +msgstr "Usuń" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "Przejdź do serialu" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Przejdź do {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "Odtwórz następne" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "Na pewno usunąć?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "Czy na pewno chcesz usunąć te multimedia?" + +msgctxt "#32328" +msgid "Yes" +msgstr "Tak" + +msgctxt "#32329" +msgid "No" +msgstr "Nie" + +msgctxt "#32330" +msgid "Message" +msgstr "Wiadomość" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "Wystąpił problem z usunięciem multimediów." + +msgctxt "#32332" +msgid "Home" +msgstr "Strona główna" + +msgctxt "#32333" +msgid "Playlists" +msgstr "Playlisty" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Potwierdź wyjście" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "Czy chcesz wyjść z Plex?" + +msgctxt "#32336" +msgid "Exit" +msgstr "Wyjdź" + +msgctxt "#32337" +msgid "Cancel" +msgstr "Anuluj" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Nie znaleziono serwerów" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "Serwer jest niedostępny" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "Trwa testowanie połączeń. Proszę czekać." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "Serwer jest niedostępny. Zaloguj się na swój serwer i sprawdź połączenie." + +msgctxt "#32342" +msgid "Switch User" +msgstr "Przełącz użytkownika" + +msgctxt "#32343" +msgid "Settings" +msgstr "Ustawienia" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "Wyloguj się" + +msgctxt "#32345" +msgid "All" +msgstr "Wszystkie" + +msgctxt "#32346" +msgid "By Name" +msgstr "Wg nazwy" + +msgctxt "#32347" +msgid "Artists" +msgstr "Wykonawcy" + +msgctxt "#32348" +msgid "movies" +msgstr "Filmy" + +msgctxt "#32349" +msgid "photos" +msgstr "Zdjęcia" + +msgctxt "#32350" +msgid "Shows" +msgstr "Seriale" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "Wg daty dodania" + +msgctxt "#32352" +msgid "Date Added" +msgstr "Data dodania" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "Wg daty wydania" + +msgctxt "#32354" +msgid "Release Date" +msgstr "Data wydania" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "Wg daty wyświetlenia" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Data wyświetlenia" + +msgctxt "#32357" +msgid "By Name" +msgstr "Wg nazwy" + +msgctxt "#32358" +msgid "Name" +msgstr "Nazwa" + +msgctxt "#32359" +msgid "By Rating" +msgstr "Wg oceny" + +msgctxt "#32360" +msgid "Rating" +msgstr "Ocena" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "Wg rozdzielczości" + +msgctxt "#32362" +msgid "Resolution" +msgstr "Rozdzielczość" + +msgctxt "#32363" +msgid "By Duration" +msgstr "Wg czasu trwania" + +msgctxt "#32364" +msgid "Duration" +msgstr "Czas trwania" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "Wg pierwszej emisji" + +msgctxt "#32366" +msgid "First Aired" +msgstr "Pierwsza emisja" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "Wg nieodtworzonych" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "Nieodtworzone" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "Wg daty odtworzenia" + +msgctxt "#32370" +msgid "Date Played" +msgstr "Data odtworzenia" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "Wg ilości odtworzeń" + +msgctxt "#32372" +msgid "Play Count" +msgstr "Ilość odtworzeń" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "Wg daty wykonania" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "Data wykonania" + +msgctxt "#32375" +msgid "No filters available" +msgstr "Brak dostępnych filtrów" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Wyczyść filtr" + +msgctxt "#32377" +msgid "Year" +msgstr "Rok" + +msgctxt "#32378" +msgid "Decade" +msgstr "Dekada" + +msgctxt "#32379" +msgid "Genre" +msgstr "Gatunek" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "Ocena treści" + +msgctxt "#32381" +msgid "Network" +msgstr "Stacja" + +msgctxt "#32382" +msgid "Collection" +msgstr "Kolekcja" + +msgctxt "#32383" +msgid "Director" +msgstr "Reżyser" + +msgctxt "#32384" +msgid "Actor" +msgstr "Aktor" + +msgctxt "#32385" +msgid "Country" +msgstr "Kraj" + +msgctxt "#32386" +msgid "Studio" +msgstr "Studio" + +msgctxt "#32387" +msgid "Labels" +msgstr "Etykiety" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "Producent aparatu" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "Model aparatu" + +msgctxt "#32390" +msgid "Aperture" +msgstr "Przysłona" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Czas migawki" + +msgctxt "#32392" +msgid "Lens" +msgstr "Obiektyw" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "Seriale" + +msgctxt "#32394" +msgid "Music" +msgstr "Muzyka" + +msgctxt "#32395" +msgid "Audio" +msgstr "Audio" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "Napisy" + +msgctxt "#32397" +msgid "Quality" +msgstr "Jakość" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Ustawienia wideo Kodi" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Ustawienia audio Kodi" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "Przejdź do sezonu" + +msgctxt "#32401" +msgid "Directors" +msgstr "Reżyserzy" + +msgctxt "#32402" +msgid "Writer" +msgstr "Scenarzysta" + +msgctxt "#32403" +msgid "Writers" +msgstr "Scenarzyści" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "Powiązane filmy" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Pobierz napisy" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Opóźnienie napisów" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Następne napisy" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Wyłącz napisy" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Włącz napisy" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "Wersja platformy" + +msgctxt "#32411" +msgid "Unknown" +msgstr "Nieznana" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Edytuj lub wyczyść" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "Edytować adres IP lub wyczyścić obecne ustawienie?" + +msgctxt "#32414" +msgid "Clear" +msgstr "Wyczyść" + +msgctxt "#32415" +msgid "Edit" +msgstr "Edytuj" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "Wprowadź adres IP" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Wprowadź numer portu" + +msgctxt "#32418" +msgid "Creator" +msgstr "Twórca" + +msgctxt "#32419" +msgid "Cast" +msgstr "Obsada" + +msgctxt "#32420" +msgid "Disc" +msgstr "Dysk" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "Wyloguj się" + +msgctxt "#32422" +msgid "Exit" +msgstr "Wyjdź" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "Zamknij" + +msgctxt "#32424" +msgid "Suspend" +msgstr "Uśpij" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "Hibernuj" + +msgctxt "#32426" +msgid "Reboot" +msgstr "Uruchom ponownie" + +msgctxt "#32427" +msgid "Failed" +msgstr "Nieudane" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "Logowanie nieudane!" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Wznów od {0}" + +msgctxt "#32430" +msgid "Discovery" +msgstr "Odkrywanie" + +msgctxt "#32431" +msgid "Search" +msgstr "Wyszukiwanie" + +msgctxt "#32432" +msgid "Space" +msgstr "Spacja" + +msgctxt "#32433" +msgid "Clear" +msgstr "Wyczyść" + +msgctxt "#32434" +msgid "Searching..." +msgstr "Wyszukiwanie..." + +msgctxt "#32435" +msgid "No Results" +msgstr "Brak wyników" + +msgctxt "#32436" +msgid "Paused" +msgstr "Wstrzymano" + +msgctxt "#32437" +msgid "Welcome" +msgstr "Witaj" + +msgctxt "#32438" +msgid "Previous" +msgstr "Poprzednie" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "Odtwarzanie następnego" + +msgctxt "#32440" +msgid "On Deck" +msgstr "Na tapecie" + +msgctxt "#32441" +msgid "Unknown" +msgstr "Nieznany" + +msgctxt "#32442" +msgid "Embedded" +msgstr "Osadzone" + +msgctxt "#32443" +msgid "Forced" +msgstr "Wymuszone" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "Tekst" + +msgctxt "#32445" +msgid "Mono" +msgstr "Mono" + +msgctxt "#32446" +msgid "Stereo" +msgstr "Stereo" + +msgctxt "#32447" +msgid "None" +msgstr "Brak" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "Odtwarzanie nieudane!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "Nie można połączyć z plex.tv[CR]Sprawdź połączenie internetowe i spróbuj ponownie." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "Wybierz wersję" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "Odtwórz wersję..." + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "Brak zawartości w tej bibliotece" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "Dodaj zawartość i/lub sprawdź, czy opcja „Załącz na pulpicie” jest włączona." + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Brak zawartości dla tego filtra" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Zmień lub usuń bieżący filtr" + +msgctxt "#32456" +msgid "Show" +msgstr "Serial" + +msgctxt "#32457" +msgid "By Show" +msgstr "Wg serialu" + +msgctxt "#32458" +msgid "Episodes" +msgstr "Odcinki(ów)" + +msgctxt "#32459" +msgid "Offline Mode" +msgstr "Tryb offline" + +msgctxt "#32460" +msgid "Sign In" +msgstr "Zaloguj się" + +msgctxt "#32461" +msgid "Albums" +msgstr "Albumy" + +msgctxt "#32462" +msgid "Artist" +msgstr "Wykonawca" + +msgctxt "#32463" +msgid "By Artist" +msgstr "Wg wykonawcy" + +msgctxt "#32464" +msgid "Player" +msgstr "Odtwarzacz" + +msgctxt "#32465" +msgid "Use skip step settings from Kodi" +msgstr "Użyj ustawień kroków przeskoku z Kodi" + +msgctxt "#32466" +msgid "Automatically seek selected position after a delay" +msgstr "Automatycznie szukaj wybranej pozycji po opóźnieniu" + +msgctxt "#32467" +msgid "User Interface" +msgstr "Interfejs użytkownika" + +msgctxt "#32471" +msgid "Use Plex/Kodi steps for timeline" +msgstr "Użyj kroków Plex/Kodi dla osi czasu" + +msgctxt "#32485" +msgid "Go back instantly with the previous menu action in scrolled views" +msgstr "Wróć natychmiast akcją menu „poprzednie” w przewijanych widokach" + +msgctxt "#32492" +msgid "Kodi Subtitle Settings" +msgstr "Ustawienia napisów Kodi" + +msgctxt "#32495" +msgid "Skip intro" +msgstr "Pomiń wstęp" diff --git a/script.plexmod/resources/language/resource.language.pt_br/strings.po b/script.plexmod/resources/language/resource.language.pt_br/strings.po new file mode 100644 index 000000000..98bd80061 --- /dev/null +++ b/script.plexmod/resources/language/resource.language.pt_br/strings.po @@ -0,0 +1,951 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: pt-BR\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" + +msgctxt "#32000" +msgid "Main" +msgstr "Principal" + +msgctxt "#32001" +msgid "Original" +msgstr "Original" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Mbps 1080p" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Mbps 1080p" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Mbps 1080p" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Mbps 1080p" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Mbps 720p" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Mbps 720p" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Mbps 720p" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Mbps 480p" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 kbps" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 kbps" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 kbps" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 kbps" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 kbps" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "Qualidade Local" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Qualidade Remota" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "Qualidade Online" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Formato de Transcodificação" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Registro de Depuração" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Permitir Reprodução Direta" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Permitir Transmissão Direta" + +msgctxt "#32027" +msgid "Force" +msgstr "Forçar" + +msgctxt "#32028" +msgid "Always" +msgstr "Sempre" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "Apenas Formatos de Imagem" + +msgctxt "#32030" +msgid "Auto" +msgstr "Auto" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "Gravar Legendas (Somente Reprodução Direta)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Permitir Conexões Inseguras" + +msgctxt "#32033" +msgid "Never" +msgstr "Nunca" + +msgctxt "#32034" +msgid "On Same network" +msgstr "Na Mesma Rede" + +msgctxt "#32035" +msgid "Always" +msgstr "Sempre" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "Permitir 4K" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "Permitir HEVC (h265)" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Conectar Automaticamente" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Reprodução Automática Após Reprodução" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Habilitar Download de Legendas" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Habilitar Download de Legendas" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Descoberta de Servidor (GDM)" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Iniciar Plex quando Kodi Iniciar" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "Conexão de IP 1" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "Conexão de Porta 1" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "Conexão de IP 2" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "Conexão de Porta 2" + +msgctxt "#32048" +msgid "Audio" +msgstr "Áudio" + +msgctxt "#32049" +msgid "Advanced" +msgstr "Avançado" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Servidores Manuais" + +msgctxt "#32051" +msgid "Privacy" +msgstr "Privacidade" + +msgctxt "#32052" +msgid "About" +msgstr "Sobre" + +msgctxt "#32053" +msgid "Video" +msgstr "Vídeo" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "Versão do Addon" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Versão do Kodi" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Resolução da Tela" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Versão Atual do Servidor" + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "Pular seleção de usuário e inserção do PIN ao iniciar." + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Se habilitado, quando a reprodução acabar e existir algum item 'A Seguir', será automáticamente reproduzido depois de 15 segudos." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Habilite isto se seu equipamento suporta reprodução 4K. Desabilite para forçar transcodificação." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Habilite isto se seu equipamento suporta HEVC/h265. Desabilite para forçar transcodificação." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Quando conectar à servidores sem conexão segura.[CR][CR]* [B]Nunca[/B]: Nunca conectar à um servidor inseguramente[CR]* [B]Na Mesma Rede[/B]: Permitir se na mesma reder[CR]* [B]Sempre[/B]: Permitir conexão na mesma rede e remotamente" + +msgctxt "#32201" +msgid "Trailer" +msgstr "Trailer" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Cena apagada" + +msgctxt "#32203" +msgid "Interview" +msgstr "Entrevista" + +msgctxt "#32204" +msgid "Music Video" +msgstr "Videoclipe" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "Por trás das cenas" + +msgctxt "#32206" +msgid "Scene" +msgstr "Cena" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Videoclipe ao vivo" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Videclipe com Letra" + +msgctxt "#32209" +msgid "Concert" +msgstr "Show" + +msgctxt "#32210" +msgid "Featurette" +msgstr "Featurette" + +msgctxt "#32211" +msgid "Short" +msgstr "Curta" + +msgctxt "#32212" +msgid "Other" +msgstr "Outro" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "Ir para Álbum" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Ir para Artista" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Ir para {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "Temporada" + +msgctxt "#32304" +msgid "Episode" +msgstr "Episódio" + +msgctxt "#32305" +msgid "Extras" +msgstr "Extras" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "Séries relacionadas" + +msgctxt "#32307" +msgid "More" +msgstr "Mais" + +msgctxt "#32308" +msgid "Available" +msgstr "Disponível" + +msgctxt "#32309" +msgid "None" +msgstr "Nenhum" + +msgctxt "#32310" +msgid "S" +msgstr "S" + +msgctxt "#32311" +msgid "E" +msgstr "E" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "Indisponível" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Este item está atualmente indisponível." + +msgctxt "#32314" +msgid "In Progress" +msgstr "Em Progresso" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "Resumir reprodução" + +msgctxt "#32316" +msgid "Resume" +msgstr "Resumir" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Reproduzir do começo" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Marcar como Não-Visto" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "Marcar como Visto" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Marcar Temporada como Não-Vista" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Marcar Temporada como Vista" + +msgctxt "#32322" +msgid "Delete" +msgstr "Apagar" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "Ir para Série" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Ir para {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "Reproduzir Próximo" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "Realmente apagar?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "Tem certeza que quer apagar esta mídia?" + +msgctxt "#32328" +msgid "Yes" +msgstr "Sim" + +msgctxt "#32329" +msgid "No" +msgstr "Não" + +msgctxt "#32330" +msgid "Message" +msgstr "Mensagem" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "Ocorreu um problema ao apagar esta mídia." + +msgctxt "#32332" +msgid "Home" +msgstr "Início" + +msgctxt "#32333" +msgid "Playlists" +msgstr "Listas de Reprodução" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Confirmar Saída" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "Está pronto para sair do Plex?" + +msgctxt "#32336" +msgid "Exit" +msgstr "Sair" + +msgctxt "#32337" +msgid "Cancel" +msgstr "Cancelar" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Nenhum Servidor Encontrado" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "Servidor inacessível" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "Testes de conexão estão em progresso. Por favor, aguarde." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "Servidor inacessível. Conecte em seu servidor e teste sua conexão." + +msgctxt "#32342" +msgid "Switch User" +msgstr "Trocar Usuário" + +msgctxt "#32343" +msgid "Settings" +msgstr "Configurações" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "Desconectar" + +msgctxt "#32345" +msgid "All" +msgstr "Tudo" + +msgctxt "#32346" +msgid "By Name" +msgstr "Por Nome" + +msgctxt "#32347" +msgid "Artists" +msgstr "Artistas" + +msgctxt "#32348" +msgid "movies" +msgstr "filmes" + +msgctxt "#32349" +msgid "photos" +msgstr "fotos" + +msgctxt "#32350" +msgid "Shows" +msgstr "Séries" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "Por Data Adicionado" + +msgctxt "#32352" +msgid "Date Added" +msgstr "Data Adicionado" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "Por Data de Lançamento" + +msgctxt "#32354" +msgid "Release Date" +msgstr "Data de Lançamento" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "Por Data de Visualização" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Data de Visualização" + +msgctxt "#32357" +msgid "By Name" +msgstr "Por Nome" + +msgctxt "#32358" +msgid "Name" +msgstr "Nome" + +msgctxt "#32359" +msgid "By Rating" +msgstr "Por Avaliação" + +msgctxt "#32360" +msgid "Rating" +msgstr "Avaliação" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "Por Resolução" + +msgctxt "#32362" +msgid "Resolution" +msgstr "Resolução" + +msgctxt "#32363" +msgid "By Duration" +msgstr "Por Duração" + +msgctxt "#32364" +msgid "Duration" +msgstr "Duração" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "Por Data de Estreia" + +msgctxt "#32366" +msgid "First Aired" +msgstr "Data de Estreia" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "Por Não-Vistos" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "Não-Vistos" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "Por Data de Reprodução" + +msgctxt "#32370" +msgid "Date Played" +msgstr "Data de Reprodução" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "Por Número de Reproduções" + +msgctxt "#32372" +msgid "Play Count" +msgstr "Número de Reproduções" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "Por Data Tirada" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "Data Tirada" + +msgctxt "#32375" +msgid "No filters available" +msgstr "Nenhum filtro disponível" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Limpar filtro" + +msgctxt "#32377" +msgid "Year" +msgstr "Ano" + +msgctxt "#32378" +msgid "Decade" +msgstr "Década" + +msgctxt "#32379" +msgid "Genre" +msgstr "Gênero" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "Classificação do Conteúdo" + +msgctxt "#32381" +msgid "Network" +msgstr "Rede" + +msgctxt "#32382" +msgid "Collection" +msgstr "Coleção" + +msgctxt "#32383" +msgid "Director" +msgstr "Diretor" + +msgctxt "#32384" +msgid "Actor" +msgstr "Ator" + +msgctxt "#32385" +msgid "Country" +msgstr "País" + +msgctxt "#32386" +msgid "Studio" +msgstr "Estúdio" + +msgctxt "#32387" +msgid "Labels" +msgstr "Rótulos" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "Fabricante da câmera" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "Modelo da câmera" + +msgctxt "#32390" +msgid "Aperture" +msgstr "Abertura" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Velocidade do Obturador" + +msgctxt "#32392" +msgid "Lens" +msgstr "Lente" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "Séries" + +msgctxt "#32394" +msgid "Music" +msgstr "Música" + +msgctxt "#32395" +msgid "Audio" +msgstr "Áudio" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "Legendas" + +msgctxt "#32397" +msgid "Quality" +msgstr "Qualidade" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Configurações de Vídeo do Kodi" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Configurações de Áudio do Kodi" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "Ir para Temporada" + +msgctxt "#32401" +msgid "Directors" +msgstr "Diretores" + +msgctxt "#32402" +msgid "Writer" +msgstr "Escritor" + +msgctxt "#32403" +msgid "Writers" +msgstr "Escritores" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "Filmes relacionados" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Baixar Legendas" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Atraso da Legenda" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Próxima Legenda" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Desabilitar Legendas" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Habilitar Legendas" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "Versão da Plataforma" + +msgctxt "#32411" +msgid "Unknown" +msgstr "Desconhecido" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Editar ou Limpar" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "Editar endereço de IP ou limpar configurações atuais?" + +msgctxt "#32414" +msgid "Clear" +msgstr "Limpar" + +msgctxt "#32415" +msgid "Edit" +msgstr "Editar" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "Insira o endereço de IP" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Insira o número da Porta" + +msgctxt "#32418" +msgid "Creator" +msgstr "Criador" + +msgctxt "#32419" +msgid "Cast" +msgstr "Elenco" + +msgctxt "#32420" +msgid "Disc" +msgstr "Disco" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "Desconectar" + +msgctxt "#32422" +msgid "Exit" +msgstr "Sair" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "Desligar" + +msgctxt "#32424" +msgid "Suspend" +msgstr "Suspender" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "Hibernar" + +msgctxt "#32426" +msgid "Reboot" +msgstr "Reiniciar" + +msgctxt "#32427" +msgid "Failed" +msgstr "Falhou" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "Login falhou!" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Resumir de {0}" + +msgctxt "#32430" +msgid "Discovery" +msgstr "Descobrir" + +msgctxt "#32431" +msgid "Search" +msgstr "Procurar" + +msgctxt "#32432" +msgid "Space" +msgstr "Espaço" + +msgctxt "#32433" +msgid "Clear" +msgstr "Limpar" + +msgctxt "#32434" +msgid "Searching..." +msgstr "Procurando..." + +msgctxt "#32435" +msgid "No Results" +msgstr "Sem Resultados" + +msgctxt "#32436" +msgid "Paused" +msgstr "Pausado" + +msgctxt "#32437" +msgid "Welcome" +msgstr "Bem-Vindo" + +msgctxt "#32438" +msgid "Previous" +msgstr "Anterior" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "Reproduzindo a seguir" + +msgctxt "#32440" +msgid "On Deck" +msgstr "No Deck" + +msgctxt "#32441" +msgid "Unknown" +msgstr "Desconhecido" + +msgctxt "#32442" +msgid "Embedded" +msgstr "Embutido" + +msgctxt "#32443" +msgid "Forced" +msgstr "Forçado" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "Letra da música" + +msgctxt "#32445" +msgid "Mono" +msgstr "Mono" + +msgctxt "#32446" +msgid "Stereo" +msgstr "Estéreo" + +msgctxt "#32447" +msgid "None" +msgstr "Nenhum" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "Reprodução falhou!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "Nao pôde conectar com o plex.tv[CR]Teste sua conexão com a internet e tente novamente." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "Escolha a Versão" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "Reproduzir Versão..." + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "Nenhum conteúdo disponível nesta biblioteca" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "Por favor adicione e/ou confirme que 'Incluir no Painel' está habilitado." + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Nenhum conteúdo disponível para este filtro" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Por favor, troque ou remova o filtro atual" + +msgctxt "#32456" +msgid "Show" +msgstr "Série" + +msgctxt "#32457" +msgid "By Show" +msgstr "Por Série" + +msgctxt "#32458" +msgid "Episodes" +msgstr "Episódios" + +msgctxt "#32459" +msgid "Offline Mode" +msgstr "Modo Offline" + +msgctxt "#32460" +msgid "Sign In" +msgstr "Conectar" + +msgctxt "#32461" +msgid "Albums" +msgstr "Álbuns" + +msgctxt "#32462" +msgid "Artist" +msgstr "Artista" + +msgctxt "#32463" +msgid "By Artist" +msgstr "Por Artista" + diff --git a/script.plexmod/resources/language/resource.language.pt_pt/strings.po b/script.plexmod/resources/language/resource.language.pt_pt/strings.po new file mode 100644 index 000000000..7c265ff3c --- /dev/null +++ b/script.plexmod/resources/language/resource.language.pt_pt/strings.po @@ -0,0 +1,980 @@ +# XBMC Media Center language file +# Guerreiro , 2019. +# +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: 2019-09-10 18:44+0100\n" +"Last-Translator: Guerreiro \n" +"Language-Team: Português <>\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: pt\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "#32000" +msgid "Main" +msgstr "Principal" + +msgctxt "#32001" +msgid "Original" +msgstr "Original" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Mbps 1080p" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Mbps 1080p" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Mbps 1080p" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Mbps 1080p" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Mbps 720p" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Mbps 720p" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Mbps 720p" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Mbps 480p" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 kbps" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 kbps" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 kbps" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 kbps" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 kbps" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "Qualidade Local" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Qualidade Remota" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "Qualidade Online" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Formato de Transcodificação" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Registo de Depuração" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Permitir Reprodução Direta" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Permitir Transmissão Direta" + +msgctxt "#32027" +msgid "Force" +msgstr "Forçar" + +msgctxt "#32028" +msgid "Always" +msgstr "Sempre" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "Apenas Formatos de Imagem" + +msgctxt "#32030" +msgid "Auto" +msgstr "Auto" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "Gravar Legendas (Somente Reprodução Direta)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Permitir Ligações Inseguras" + +msgctxt "#32033" +msgid "Never" +msgstr "Nunca" + +msgctxt "#32034" +msgid "On Same network" +msgstr "Na Mesma Rede" + +msgctxt "#32035" +msgid "Always" +msgstr "Sempre" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "Permitir 4K" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "Permitir HEVC (h265)" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Entrar Automaticamente" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Reprodução Automática" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Ativar a Transferência de Legendas" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Ativar a Transferência de Legendas" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Descoberta de Servidor (GDM)" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Iniciar Plex Automaticamente" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "IP de Ligação 1" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "Porta de Ligação 1" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "IP de Ligação 2" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "Porta de Ligação 2" + +msgctxt "#32048" +msgid "Audio" +msgstr "Áudio" + +msgctxt "#32049" +msgid "Advanced" +msgstr "Avançado" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Servidores Manuais" + +msgctxt "#32051" +msgid "Privacy" +msgstr "Privacidade" + +msgctxt "#32052" +msgid "About" +msgstr "Sobre" + +msgctxt "#32053" +msgid "Video" +msgstr "Vídeo" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "Versão do Addon" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Versão do Kodi" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Resolução de Ecrã" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Versão Atual do Servidor" + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "Saltar seleção de utilizador e inserção do PIN ao iniciar." + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Se ativado, quando a reprodução acabar e existir algum item 'A Seguir', será automáticamente reproduzido depois de 15 segundos." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Ative isto se seu equipamento suportar reprodução 4K. Desative para forçar conversão." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Ative isto se seu equipamento suportar HEVC/h265. Desative para forçar conversão." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Quando ligar à servidores sem ligação segura.[CR][CR]* [B]Nunca[/B]: Nunca ligar à um servidor de forma insegura[CR]* [B]Na Mesma Rede[/B]: Permitir se na mesma rede[CR]* [B]Sempre[/B]: Permitir ligação na mesma rede e remotamente" + +msgctxt "#32201" +msgid "Trailer" +msgstr "Trailer" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Cena apagada" + +msgctxt "#32203" +msgid "Interview" +msgstr "Entrevista" + +msgctxt "#32204" +msgid "Music Video" +msgstr "Vídeo Clipe" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "Nos Bastidores" + +msgctxt "#32206" +msgid "Scene" +msgstr "Cena" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Vídeo Clipe ao Vivo" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Vide Clipe com Letra" + +msgctxt "#32209" +msgid "Concert" +msgstr "Concerto" + +msgctxt "#32210" +msgid "Featurette" +msgstr "Destaques" + +msgctxt "#32211" +msgid "Short" +msgstr "Curta" + +msgctxt "#32212" +msgid "Other" +msgstr "Outro" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "Ir para Álbum" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Ir para Artista" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Ir para {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "Temporada" + +msgctxt "#32304" +msgid "Episode" +msgstr "Episódio" + +msgctxt "#32305" +msgid "Extras" +msgstr "Extras" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "Séries relacionadas" + +msgctxt "#32307" +msgid "More" +msgstr "Mais" + +msgctxt "#32308" +msgid "Available" +msgstr "Disponível" + +msgctxt "#32309" +msgid "None" +msgstr "Nenhum" + +msgctxt "#32310" +msgid "S" +msgstr "T" + +msgctxt "#32311" +msgid "E" +msgstr "E" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "Indisponível" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Este item está atualmente indisponível." + +msgctxt "#32314" +msgid "In Progress" +msgstr "Em Progresso" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "Resumir reprodução" + +msgctxt "#32316" +msgid "Resume" +msgstr "Resumir" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Reproduzir do inicio" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Marcar como Não-Visto" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "Marcar como Visto" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Marcar Temporada como Não-Vista" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Marcar Temporada como Vista" + +msgctxt "#32322" +msgid "Delete" +msgstr "Apagar" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "Ir para Série" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Ir para {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "Reproduzir Próximo" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "Realmente Apagar?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "Tem certeza que quer apagar este item?" + +msgctxt "#32328" +msgid "Yes" +msgstr "Sim" + +msgctxt "#32329" +msgid "No" +msgstr "Não" + +msgctxt "#32330" +msgid "Message" +msgstr "Mensagem" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "Ocorreu um problema ao apagar este item." + +msgctxt "#32332" +msgid "Home" +msgstr "Início" + +msgctxt "#32333" +msgid "Playlists" +msgstr "Listas de Reprodução" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Confirmar Saída" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "Está pronto para sair do Plex?" + +msgctxt "#32336" +msgid "Exit" +msgstr "Sair" + +msgctxt "#32337" +msgid "Cancel" +msgstr "Cancelar" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Nenhum Servidor Encontrado" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "Servidor inacessível" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "Testes de ligação estão em progresso. Por favor, aguarde." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "Servidor inacessível. Ligue-se ao servidor e teste a ligação." + +msgctxt "#32342" +msgid "Switch User" +msgstr "Mudar de Utilizador" + +msgctxt "#32343" +msgid "Settings" +msgstr "Definições" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "Terminar Sessão" + +msgctxt "#32345" +msgid "All" +msgstr "Tudo" + +msgctxt "#32346" +msgid "By Name" +msgstr "Por Nome" + +msgctxt "#32347" +msgid "Artists" +msgstr "Artistas" + +msgctxt "#32348" +msgid "Movies" +msgstr "Filmes" + +msgctxt "#32349" +msgid "photos" +msgstr "fotos" + +msgctxt "#32350" +msgid "Shows" +msgstr "Séries" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "Por Data Adicionado" + +msgctxt "#32352" +msgid "Date Added" +msgstr "Data Adicionado" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "Por Data de Lançamento" + +msgctxt "#32354" +msgid "Release Date" +msgstr "Data de Lançamento" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "Por Data de Visualização" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Data de Visualização" + +msgctxt "#32357" +msgid "By Name" +msgstr "Por Nome" + +msgctxt "#32358" +msgid "Name" +msgstr "Nome" + +msgctxt "#32359" +msgid "By Rating" +msgstr "Por Avaliação" + +msgctxt "#32360" +msgid "Rating" +msgstr "Avaliação" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "Por Resolução" + +msgctxt "#32362" +msgid "Resolution" +msgstr "Resolução" + +msgctxt "#32363" +msgid "By Duration" +msgstr "Por Duração" + +msgctxt "#32364" +msgid "Duration" +msgstr "Duração" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "Por Data de Estreia" + +msgctxt "#32366" +msgid "First Aired" +msgstr "Data de Estreia" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "Por Não-Vistos" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "Não-Vistos" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "Por Data de Reprodução" + +msgctxt "#32370" +msgid "Date Played" +msgstr "Data de Reprodução" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "Por Número de Reproduções" + +msgctxt "#32372" +msgid "Play Count" +msgstr "Número de Reproduções" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "Por Data Tirada" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "Data Tirada" + +msgctxt "#32375" +msgid "No filters available" +msgstr "Nenhum filtro disponível" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Limpar filtro" + +msgctxt "#32377" +msgid "Year" +msgstr "Ano" + +msgctxt "#32378" +msgid "Decade" +msgstr "Década" + +msgctxt "#32379" +msgid "Genre" +msgstr "Género" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "Classificação do Conteúdo" + +msgctxt "#32381" +msgid "Network" +msgstr "Rede" + +msgctxt "#32382" +msgid "Collection" +msgstr "Coleção" + +msgctxt "#32383" +msgid "Director" +msgstr "Diretor" + +msgctxt "#32384" +msgid "Actor" +msgstr "Ator" + +msgctxt "#32385" +msgid "Country" +msgstr "País" + +msgctxt "#32386" +msgid "Studio" +msgstr "Estúdio" + +msgctxt "#32387" +msgid "Labels" +msgstr "Rótulos" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "Fabricante da câmara" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "Modelo da câmara" + +msgctxt "#32390" +msgid "Aperture" +msgstr "Abertura" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Velocidade do Obturador" + +msgctxt "#32392" +msgid "Lens" +msgstr "Lente" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "Séries TV" + +msgctxt "#32394" +msgid "Music" +msgstr "Música" + +msgctxt "#32395" +msgid "Audio" +msgstr "Áudio" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "Legendas" + +msgctxt "#32397" +msgid "Quality" +msgstr "Qualidade" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Configurações de Vídeo do Kodi" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Configurações de Áudio do Kodi" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "Ir para Temporada" + +msgctxt "#32401" +msgid "Directors" +msgstr "Diretores" + +msgctxt "#32402" +msgid "Writer" +msgstr "Escritor" + +msgctxt "#32403" +msgid "Writers" +msgstr "Escritores" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "Filmes relacionados" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Transferir Legendas" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Atraso da Legenda" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Próxima Legenda" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Desativar Legendas" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Ativar Legendas" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "Versão da Plataforma" + +msgctxt "#32411" +msgid "Unknown" +msgstr "Desconhecido" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Editar ou Limpar" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "Editar endereço de IP ou limpar configurações atuais?" + +msgctxt "#32414" +msgid "Clear" +msgstr "Limpar" + +msgctxt "#32415" +msgid "Edit" +msgstr "Editar" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "Insira o endereço de IP" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Insira o número da Porta" + +msgctxt "#32418" +msgid "Creator" +msgstr "Criador" + +msgctxt "#32419" +msgid "Cast" +msgstr "Elenco" + +msgctxt "#32420" +msgid "Disc" +msgstr "Disco" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "Terminar Sessão" + +msgctxt "#32422" +msgid "Exit" +msgstr "Sair" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "Desligar" + +msgctxt "#32424" +msgid "Suspend" +msgstr "Suspender" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "Hibernar" + +msgctxt "#32426" +msgid "Reboot" +msgstr "Reiniciar" + +msgctxt "#32427" +msgid "Failed" +msgstr "Falhou" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "Falha ao iniciar sessão!" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Resumir de {0}" + +msgctxt "#32430" +msgid "Discovery" +msgstr "Descobrir" + +msgctxt "#32431" +msgid "Search" +msgstr "Procurar" + +msgctxt "#32432" +msgid "Space" +msgstr "Espaço" + +msgctxt "#32433" +msgid "Clear" +msgstr "Limpar" + +msgctxt "#32434" +msgid "Searching..." +msgstr "A Procurar..." + +msgctxt "#32435" +msgid "No Results" +msgstr "Sem Resultados" + +msgctxt "#32436" +msgid "Paused" +msgstr "Em Pausa" + +msgctxt "#32437" +msgid "Welcome" +msgstr "Bem-Vindo" + +msgctxt "#32438" +msgid "Previous" +msgstr "Anterior" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "Reproduzindo a seguir" + +msgctxt "#32440" +msgid "On Deck" +msgstr "Próximos a Ver" + +msgctxt "#32441" +msgid "Unknown" +msgstr "Desconhecido" + +msgctxt "#32442" +msgid "Embedded" +msgstr "Embutido" + +msgctxt "#32443" +msgid "Forced" +msgstr "Forçado" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "Letra da música" + +msgctxt "#32445" +msgid "Mono" +msgstr "Mono" + +msgctxt "#32446" +msgid "Stereo" +msgstr "Estéreo" + +msgctxt "#32447" +msgid "None" +msgstr "Nenhum" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "Falha ao Reproduzir!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "Não foi possivel ligar ao plex.tv[CR]Teste a ligação de internet e tente novamente." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "Escolher Versão" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "Reproduzir Versão..." + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "Nenhum conteúdo disponível nesta biblioteca" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "Por favor adicione e/ou confirme que 'Incluir no Painel' está desativado." + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Nenhum conteúdo disponível para este filtro" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Por favor, troque ou remova o filtro atual" + +msgctxt "#32456" +msgid "Show" +msgstr "Série" + +msgctxt "#32457" +msgid "By Show" +msgstr "Por Série" + +msgctxt "#32458" +msgid "Episodes" +msgstr "Episódios" + +msgctxt "#32459" +msgid "Offline Mode" +msgstr "Modo Offline" + +msgctxt "#32460" +msgid "Sign In" +msgstr "Iniciar Sessão" + +msgctxt "#32461" +msgid "Albums" +msgstr "Álbuns" + +msgctxt "#32462" +msgid "Artist" +msgstr "Artista" + +msgctxt "#32463" +msgid "By Artist" +msgstr "Por Artista" + +msgctxt "#32464" +msgid "Player" +msgstr "Reprodutor" + +msgctxt "#32465" +msgid "Use skip step settings from Kodi" +msgstr "Use as definições de saltar etapas do Kodi" + +msgctxt "#32466" +msgid "Automatically seek selected position after a delay" +msgstr "Procurar automaticamente a posição selecionada após atrasar" + +msgctxt "#32467" +msgid "User Interface" +msgstr "Interface de Utilizador" + +msgctxt "#32471" +msgid "Use Plex/Kodi steps for timeline" +msgstr "Use as etapas do Plex/Kodi para a linha de tempo" + +msgctxt "#32485" +msgid "Go back instantly with the previous menu action in scrolled views" +msgstr "Recuar instantaneamente com a ação recuar do menu em visualizações deslocáveis" + +msgctxt "#32492" +msgid "Kodi Subtitle Settings" +msgstr "Definições de Legendas do Kodi" diff --git a/script.plexmod/resources/language/resource.language.ru_ru/strings.po b/script.plexmod/resources/language/resource.language.ru_ru/strings.po new file mode 100644 index 000000000..50b5ba08d --- /dev/null +++ b/script.plexmod/resources/language/resource.language.ru_ru/strings.po @@ -0,0 +1,951 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2013-12-12 22:56+0000\n" +"PO-Revision-Date: 2018-01-15 11:26+0300\n" +"Language-Team: LANGUAGE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: ru\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"Last-Translator: \n" +"X-Generator: Poedit 2.0.5\n" + +msgctxt "#32000" +msgid "Main" +msgstr "Главный" + +msgctxt "#32001" +msgid "Original" +msgstr "Оригинал" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "20 Мбит/с 1080p" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "12 Мбит/с 1080p" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "10 Мбит/с 1080p" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "8 Мбит/с 1080p" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "4 Мбит/с 720p" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "3 Мбит/с 720p" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "2 Мбит/с 720p" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "1.5 Мбит/с 480p" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "720 кбит/с" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "320 кбит/с" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "208 кбит/с" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "96 кбит/с" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "64 кбит/с" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "Локальное качество" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "Удаленное качество" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "Онлайн качество" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "Формат перекодирования" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "Отладка" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "Разрешить прямое воспроизведение" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "Разрешить прямую трансляцию" + +msgctxt "#32027" +msgid "Force" +msgstr "Принудительно" + +msgctxt "#32028" +msgid "Always" +msgstr "Всегда" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "Только изображения" + +msgctxt "#32030" +msgid "Auto" +msgstr "Авто" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "Наложение субтитров (только прямое воспроизведение)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "Разрешить небезопасные соединения" + +msgctxt "#32033" +msgid "Never" +msgstr "Никогда" + +msgctxt "#32034" +msgid "On Same network" +msgstr "В той же сети" + +msgctxt "#32035" +msgid "Always" +msgstr "Всегда" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "Разрешить 4K" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "Разрешить HEVC (h265)" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "Автоматический вход" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "Автоматическое воспроизведение" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "Включить скачивание субтитров" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "Включить скачивание субтитров" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "Обнаружение сервера (GDM)" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "Запускать Plex при старте Kodi" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "Соединение 1 IP" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "Соединение 1 порт" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "Соединение 2 IP" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "Соединение 2 порт" + +msgctxt "#32048" +msgid "Audio" +msgstr "Аудио" + +msgctxt "#32049" +msgid "Advanced" +msgstr "Продвинутый" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "Задать сервер вручную" + +msgctxt "#32051" +msgid "Privacy" +msgstr "Приватность" + +msgctxt "#32052" +msgid "About" +msgstr "Сведения" + +msgctxt "#32053" +msgid "Video" +msgstr "Видео" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "Версия аддона" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Версия Kodi" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "Разрешение экрана" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "Версия сервера" + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "Пропустить выбор пользователя и ввод пин-кода при старте." + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "Если включено, после окончании воспроизведения, если есть следующая запись, она будет автоматически запущена после 15 секундной задержки." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "Включите, если ваше оборудование поддерживает воспроизведение 4K. Отключите, чтобы использовать преобразование." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "Включите, если ваше оборудование поддерживает HEVC/h265. Отключите, чтобы использовать преобразование." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "Когда подключаться к серверам, не поддерживающим безопасные соединения.[CR][CR]* [B]Никогда[/B]: Никогда не подключаться к серверам с небезопасным соединением[CR]* [B]В той же сети[/B]: Разрешить, если сервер находится в той же сети[CR]* [B]Всегда[/B]: Разрешить в той же сети и удаленные соединения" + +msgctxt "#32201" +msgid "Trailer" +msgstr "Трейлер" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "Удаленные эпизоды" + +msgctxt "#32203" +msgid "Interview" +msgstr "Интервью" + +msgctxt "#32204" +msgid "Music Video" +msgstr "Клип" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "За кулисами" + +msgctxt "#32206" +msgid "Scene" +msgstr "Эпизод" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "Живая музыка" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "Лирический клип" + +msgctxt "#32209" +msgid "Concert" +msgstr "Концерт" + +msgctxt "#32210" +msgid "Featurette" +msgstr "Короткометражный фильм" + +msgctxt "#32211" +msgid "Short" +msgstr "Короткий" + +msgctxt "#32212" +msgid "Other" +msgstr "Прочее" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "Перейти к альбому" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "Перейти к артисту" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "Перейти к {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "Сезон" + +msgctxt "#32304" +msgid "Episode" +msgstr "Эпизод" + +msgctxt "#32305" +msgid "Extras" +msgstr "Дополнения" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "Связанные вупыски" + +msgctxt "#32307" +msgid "More" +msgstr "Еще" + +msgctxt "#32308" +msgid "Available" +msgstr "Доступно" + +msgctxt "#32309" +msgid "None" +msgstr "Нет" + +msgctxt "#32310" +msgid "S" +msgstr "S" + +msgctxt "#32311" +msgid "E" +msgstr "E" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "Недосутпно" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "Эта запись сейчас недоступна." + +msgctxt "#32314" +msgid "In Progress" +msgstr "В работе" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "Возобновить воспроизведение?" + +msgctxt "#32316" +msgid "Resume" +msgstr "Возобновить" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "Начать сначала" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "Пометить как не просмотренное" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "Пометить как просмотренное" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "Пометить сезон как не просмотренный" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "Пометить сезон как просмотренный" + +msgctxt "#32322" +msgid "Delete" +msgstr "Удалить" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "Перейти к просмотру" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "Перейти к {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "Воспроизвести следующий" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "Действительно удалить?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "Вы действительно хотите удалить?" + +msgctxt "#32328" +msgid "Yes" +msgstr "Да" + +msgctxt "#32329" +msgid "No" +msgstr "Нет" + +msgctxt "#32330" +msgid "Message" +msgstr "Сообщение" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "Возникла проблема при удалении." + +msgctxt "#32332" +msgid "Home" +msgstr "Домой" + +msgctxt "#32333" +msgid "Playlists" +msgstr "Плейлисты" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "Подтверждение выхода" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "Вы действительно хотите выйти из Plex?" + +msgctxt "#32336" +msgid "Exit" +msgstr "Выход" + +msgctxt "#32337" +msgid "Cancel" +msgstr "Отмена" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "Сервера не найдены" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "Сервер недоступен" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "Пожалуйста, подождите, идет проверка соединения." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "Сервер недоступен. Пожалуйста, войдите на ваш сервер и проверьте соединение." + +msgctxt "#32342" +msgid "Switch User" +msgstr "Сменить пользователя" + +msgctxt "#32343" +msgid "Settings" +msgstr "Настройки" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "Выйти" + +msgctxt "#32345" +msgid "All" +msgstr "Все" + +msgctxt "#32346" +msgid "By Name" +msgstr "По имени" + +msgctxt "#32347" +msgid "Artists" +msgstr "Артисты" + +msgctxt "#32348" +msgid "movies" +msgstr "фильмы" + +msgctxt "#32349" +msgid "photos" +msgstr "фото" + +msgctxt "#32350" +msgid "Shows" +msgstr "Сериалы" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "По дате добавления" + +msgctxt "#32352" +msgid "Date Added" +msgstr "Дата добавления" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "По дате выхода" + +msgctxt "#32354" +msgid "Release Date" +msgstr "Дата выхода" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "По дате просмотра" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "Дата просмотра" + +msgctxt "#32357" +msgid "By Name" +msgstr "По имени" + +msgctxt "#32358" +msgid "Name" +msgstr "Имя" + +msgctxt "#32359" +msgid "By Rating" +msgstr "По рейтингу" + +msgctxt "#32360" +msgid "Rating" +msgstr "Рейтинг" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "По разрешению" + +msgctxt "#32362" +msgid "Resolution" +msgstr "Разрешение" + +msgctxt "#32363" +msgid "By Duration" +msgstr "По длительности" + +msgctxt "#32364" +msgid "Duration" +msgstr "Длительность" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "По дате показа" + +msgctxt "#32366" +msgid "First Aired" +msgstr "Дата показа" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "По не просмотренным" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "Не просмотренные" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "По дате воспроизведения" + +msgctxt "#32370" +msgid "Date Played" +msgstr "Дата воспроизведения" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "По количеству просмотров" + +msgctxt "#32372" +msgid "Play Count" +msgstr "Количество просмотров" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "По выбранной дате" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "Выбранная дата" + +msgctxt "#32375" +msgid "No filters available" +msgstr "Нет фильтров" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "Очистить фильтр" + +msgctxt "#32377" +msgid "Year" +msgstr "Год" + +msgctxt "#32378" +msgid "Decade" +msgstr "Десятилетие" + +msgctxt "#32379" +msgid "Genre" +msgstr "Жанр" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "Рейтинг контента" + +msgctxt "#32381" +msgid "Network" +msgstr "Сеть" + +msgctxt "#32382" +msgid "Collection" +msgstr "Коллекция" + +msgctxt "#32383" +msgid "Director" +msgstr "Режиссер" + +msgctxt "#32384" +msgid "Actor" +msgstr "Актер" + +msgctxt "#32385" +msgid "Country" +msgstr "Страна" + +msgctxt "#32386" +msgid "Studio" +msgstr "Студия" + +msgctxt "#32387" +msgid "Labels" +msgstr "Лейбл" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "Камера" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "Модель камеры" + +msgctxt "#32390" +msgid "Aperture" +msgstr "Диафрагма" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "Скорость затвора" + +msgctxt "#32392" +msgid "Lens" +msgstr "Объектив" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "Сериалы" + +msgctxt "#32394" +msgid "Music" +msgstr "Музыка" + +msgctxt "#32395" +msgid "Audio" +msgstr "Аудио" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "Субтитры" + +msgctxt "#32397" +msgid "Quality" +msgstr "Качетсво" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "Настройки видео Kodi" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "Настройки аудио Kodi" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "Перейти к сезону" + +msgctxt "#32401" +msgid "Directors" +msgstr "Режиссеры" + +msgctxt "#32402" +msgid "Writer" +msgstr "Сценарист" + +msgctxt "#32403" +msgid "Writers" +msgstr "Сценаристы" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "Похожие фильмы" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "Скачать субтитры" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "Задержка субтитров" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "Следующие субтитры" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "Отключить субтитры" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "Включить субтитры" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "Версия платформы" + +msgctxt "#32411" +msgid "Unknown" +msgstr "Неизсвестно" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "Изменить или удалить" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "Изменить IP адрес или удалить настройки?" + +msgctxt "#32414" +msgid "Clear" +msgstr "Удалить" + +msgctxt "#32415" +msgid "Edit" +msgstr "Изменить" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "Введите IP адрес" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "Введите номер порта" + +msgctxt "#32418" +msgid "Creator" +msgstr "Автор" + +msgctxt "#32419" +msgid "Cast" +msgstr "В ролях" + +msgctxt "#32420" +msgid "Disc" +msgstr "Диск" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "Выйти из учетной записи" + +msgctxt "#32422" +msgid "Exit" +msgstr "Выход" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "Завершить работу" + +msgctxt "#32424" +msgid "Suspend" +msgstr "Перейти в режим сна" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "Гибернация" + +msgctxt "#32426" +msgid "Reboot" +msgstr "Перезагрузка" + +msgctxt "#32427" +msgid "Failed" +msgstr "Ошибка" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "Ошибка входа!" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "Начать с {0}" + +msgctxt "#32430" +msgid "Discovery" +msgstr "Обнаружение" + +msgctxt "#32431" +msgid "Search" +msgstr "Поиск" + +msgctxt "#32432" +msgid "Space" +msgstr "Пробел" + +msgctxt "#32433" +msgid "Clear" +msgstr "Удалить" + +msgctxt "#32434" +msgid "Searching..." +msgstr "Поиск..." + +msgctxt "#32435" +msgid "No Results" +msgstr "Нет результатов" + +msgctxt "#32436" +msgid "Paused" +msgstr "Пауза" + +msgctxt "#32437" +msgid "Welcome" +msgstr "Добро пожаловать" + +msgctxt "#32438" +msgid "Previous" +msgstr "Предыдущий" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "Следующий" + +msgctxt "#32440" +msgid "On Deck" +msgstr "Сейчас просматривается" + +msgctxt "#32441" +msgid "Unknown" +msgstr "Неизвестно" + +msgctxt "#32442" +msgid "Embedded" +msgstr "Встроенный" + +msgctxt "#32443" +msgid "Forced" +msgstr "Принудительно" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "Текст песни" + +msgctxt "#32445" +msgid "Mono" +msgstr "Моно" + +msgctxt "#32446" +msgid "Stereo" +msgstr "Стерео" + +msgctxt "#32447" +msgid "None" +msgstr "Нет" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "Ошибка воспроизведения!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "Нет соединения с plex.tv[CR]Проверьте ваше интернет-соединение и попробуйте еще раз." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "Выберите версию" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "Версия воспроизведения..." + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "В этой библиотеке ничего нет" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "Пожалуйста, добавьте контент и/или проверьте включено ли \"Добавить на панель\"." + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "Нет результатов с выбранными условиями" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "Пожалуйста, измените или удалите текущий фильтр" + +msgctxt "#32456" +msgid "Show" +msgstr "Показ" + +msgctxt "#32457" +msgid "By Show" +msgstr "По показу" + +msgctxt "#32458" +msgid "Episodes" +msgstr "Эпизоды" + +msgctxt "#32459" +msgid "Offline Mode" +msgstr "Автономный режим" + +msgctxt "#32460" +msgid "Sign In" +msgstr "Войти" + +msgctxt "#32461" +msgid "Albums" +msgstr "Альбомы" + +msgctxt "#32462" +msgid "Artist" +msgstr "Артист" + +msgctxt "#32463" +msgid "By Artist" +msgstr "По артисту" diff --git a/script.plexmod/resources/language/resource.language.zh_cn/strings.po b/script.plexmod/resources/language/resource.language.zh_cn/strings.po new file mode 100644 index 000000000..46b8c93da --- /dev/null +++ b/script.plexmod/resources/language/resource.language.zh_cn/strings.po @@ -0,0 +1,1075 @@ +# XBMC Media Center language file +msgid "" +msgstr "" +"Project-Id-Version: XBMC-Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: 2020-3-4 22:56+0000\n" +"PO-Revision-Date: 2022-02-08 19:00+0800\n" +"Last-Translator: Qianyu Li \n" +"Language-Team: LANGUAGE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: zh_CN\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +msgctxt "#32000" +msgid "Main" +msgstr "一般" + +msgctxt "#32001" +msgid "Original" +msgstr "原始" + +msgctxt "#32002" +msgid "20 Mbps 1080p" +msgstr "" + +msgctxt "#32003" +msgid "12 Mbps 1080p" +msgstr "" + +msgctxt "#32004" +msgid "10 Mbps 1080p" +msgstr "" + +msgctxt "#32005" +msgid "8 Mbps 1080p" +msgstr "" + +msgctxt "#32006" +msgid "4 Mbps 720p" +msgstr "" + +msgctxt "#32007" +msgid "3 Mbps 720p" +msgstr "" + +msgctxt "#32008" +msgid "2 Mbps 720p" +msgstr "" + +msgctxt "#32009" +msgid "1.5 Mbps 480p" +msgstr "" + +msgctxt "#32010" +msgid "720 kbps" +msgstr "" + +msgctxt "#32011" +msgid "320 kbps" +msgstr "" + +msgctxt "#32012" +msgid "208 kbps" +msgstr "" + +msgctxt "#32013" +msgid "96 kbps" +msgstr "" + +msgctxt "#32014" +msgid "64 kbps" +msgstr "" + +msgctxt "#32020" +msgid "Local Quality" +msgstr "本地播放质量" + +msgctxt "#32021" +msgid "Remote Quality" +msgstr "远程播放质量" + +msgctxt "#32022" +msgid "Online Quality" +msgstr "在线播放质量" + +msgctxt "#32023" +msgid "Transcode Format" +msgstr "转码格式" + +msgctxt "#32024" +msgid "Debug Logging" +msgstr "调试日志" + +msgctxt "#32025" +msgid "Allow Direct Play" +msgstr "允许直接播放" + +msgctxt "#32026" +msgid "Allow Direct Stream" +msgstr "允许直连串流" + +msgctxt "#32027" +msgid "Force" +msgstr "强制直接播放" + +msgctxt "#32028" +msgid "Always" +msgstr "总是" + +msgctxt "#32029" +msgid "Only Image Formats" +msgstr "仅图像格式" + +msgctxt "#32030" +msgid "Auto" +msgstr "自动" + +msgctxt "#32031" +msgid "Burn Subtitles (Direct Play Only)" +msgstr "固化字幕(仅直接播放)" + +msgctxt "#32032" +msgid "Allow Insecure Connections" +msgstr "允许非加密连接" + +msgctxt "#32033" +msgid "Never" +msgstr "从不" + +msgctxt "#32034" +msgid "On Same network" +msgstr "在同一网络" + +msgctxt "#32035" +msgid "Always" +msgstr "总是" + +msgctxt "#32036" +msgid "Allow 4K" +msgstr "允许4K" + +msgctxt "#32037" +msgid "Allow HEVC (h265)" +msgstr "允许HEVC (h265)" + +msgctxt "#32038" +msgid "Automatically Sign In" +msgstr "自动登录" + +msgctxt "#32039" +msgid "Post Play Auto Play" +msgstr "点击海报自动播放" + +msgctxt "#32040" +msgid "Enable Subtitle Downloading" +msgstr "允许下载字幕" + +msgctxt "#32041" +msgid "Enable Subtitle Downloading" +msgstr "允许下载字幕" + +msgctxt "#32042" +msgid "Server Discovery (GDM)" +msgstr "发现服务器(GDM)" + +msgctxt "#32043" +msgid "Start Plex On Kodi Startup" +msgstr "打开Kodi时自动启动PLEX" + +msgctxt "#32044" +msgid "Connection 1 IP" +msgstr "服务器1的IP" + +msgctxt "#32045" +msgid "Connection 1 Port" +msgstr "服务器1的端口" + +msgctxt "#32046" +msgid "Connection 2 IP" +msgstr "服务器2的IP" + +msgctxt "#32047" +msgid "Connection 2 Port" +msgstr "服务器2的端口" + +msgctxt "#32048" +msgid "Audio" +msgstr "音频" + +msgctxt "#32049" +msgid "Advanced" +msgstr "高级" + +msgctxt "#32050" +msgid "Manual Servers" +msgstr "手动输入服务器地址" + +msgctxt "#32051" +msgid "Privacy" +msgstr "隐私" + +msgctxt "#32052" +msgid "About" +msgstr "关于" + +msgctxt "#32053" +msgid "Video" +msgstr "视频" + +msgctxt "#32054" +msgid "Addon Version" +msgstr "插件版本" + +msgctxt "#32055" +msgid "Kodi Version" +msgstr "Kodi版本" + +msgctxt "#32056" +msgid "Screen Resolution" +msgstr "屏幕分辨率" + +msgctxt "#32057" +msgid "Current Server Version" +msgstr "PLEX服务器版本" + +msgctxt "#32100" +msgid "Skip user selection and pin entry on startup." +msgstr "启动时跳过选择用户和图钉." + +msgctxt "#32101" +msgid "If enabled, when playback ends and there is a 'Next Up' item available, it will be automatically be played after a 15 second delay." +msgstr "如果启用,当播放结束并且有一个“Next Up”项目可用时,它将在15秒的延迟后自动播放." + +msgctxt "#32102" +msgid "Enable this if your hardware can handle 4K playback. Disable it to force transcoding." +msgstr "如果您的硬件可以处理4K播放,请启用此选项.禁用它以强制转码." + +msgctxt "#32103" +msgid "Enable this if your hardware can handle HEVC/h265. Disable it to force transcoding." +msgstr "如果您的硬件可以处理HEVC/h265,请启用此选项.禁用它以强制转码." + +msgctxt "#32104" +msgid "When to connect to servers with no secure connections.[CR][CR]* [B]Never[/B]: Never connect to a server insecurely[CR]* [B]On Same Network[/B]: Allow if on the same network[CR]* [B]Always[/B]: Allow same network and remote connections" +msgstr "何时连接到没有安全连接的服务器。[CR][CR]* [B]从不[/B]: 从不以不安全方式连接服务器[CR]* [B]在同一网络[/B]: 允许同一网络时[CR]* [B]总是[/B]: 允许同一网络和远程连接时" + +msgctxt "#32201" +msgid "Trailer" +msgstr "预告片" + +msgctxt "#32202" +msgid "Deleted Scene" +msgstr "删除的场景" + +msgctxt "#32203" +msgid "Interview" +msgstr "访问" + +msgctxt "#32204" +msgid "Music Video" +msgstr "音乐视频" + +msgctxt "#32205" +msgid "Behind the Scenes" +msgstr "幕后花絮" + +msgctxt "#32206" +msgid "Scene" +msgstr "现场" + +msgctxt "#32207" +msgid "Live Music Video" +msgstr "现场音乐视频" + +msgctxt "#32208" +msgid "Lyric Music Video" +msgstr "歌词音乐视频" + +msgctxt "#32209" +msgid "Concert" +msgstr "演奏会" + +msgctxt "#32210" +msgid "Featurette" +msgstr "电影短片" + +msgctxt "#32211" +msgid "Short" +msgstr "短篇" + +msgctxt "#32212" +msgid "Other" +msgstr "其他" + +msgctxt "#32300" +msgid "Go to Album" +msgstr "跳转到相册" + +msgctxt "#32301" +msgid "Go to Artist" +msgstr "跳转到演员" + +msgctxt "#32302" +msgid "Go to {0}" +msgstr "跳转到 {0}" + +msgctxt "#32303" +msgid "Season" +msgstr "季数" + +msgctxt "#32304" +msgid "Episode" +msgstr "集数" + +msgctxt "#32305" +msgid "Extras" +msgstr "其他" + +msgctxt "#32306" +msgid "Related Shows" +msgstr "相关节目" + +msgctxt "#32307" +msgid "More" +msgstr "更多" + +msgctxt "#32308" +msgid "Available" +msgstr "可用的" + +msgctxt "#32309" +msgid "None" +msgstr "无" + +msgctxt "#32310" +msgid "S" +msgstr "" + +msgctxt "#32311" +msgid "E" +msgstr "" + +msgctxt "#32312" +msgid "Unavailable" +msgstr "不可用" + +msgctxt "#32313" +msgid "This item is currently unavailable." +msgstr "这个项目现在不可用." + +msgctxt "#32314" +msgid "In Progress" +msgstr "进行中" + +msgctxt "#32315" +msgid "Resume playback?" +msgstr "恢复播放?" + +msgctxt "#32316" +msgid "Resume" +msgstr "恢复" + +msgctxt "#32317" +msgid "Play from beginning" +msgstr "从头播放" + +msgctxt "#32318" +msgid "Mark Unplayed" +msgstr "标记为未播放" + +msgctxt "#32319" +msgid "Mark Played" +msgstr "标记为已播放" + +msgctxt "#32320" +msgid "Mark Season Unplayed" +msgstr "标记整季未播放" + +msgctxt "#32321" +msgid "Mark Season Played" +msgstr "标记整季已播放" + +msgctxt "#32322" +msgid "Delete" +msgstr "删除" + +msgctxt "#32323" +msgid "Go To Show" +msgstr "转到剧集" + +msgctxt "#32324" +msgid "Go To {0}" +msgstr "跳转到 {0}" + +msgctxt "#32325" +msgid "Play Next" +msgstr "播放下一个" + +msgctxt "#32326" +msgid "Really Delete?" +msgstr "确定删除?" + +msgctxt "#32327" +msgid "Are you sure you really want to delete this media?" +msgstr "你确定你要删除这个媒体么?" + +msgctxt "#32328" +msgid "Yes" +msgstr "是" + +msgctxt "#32329" +msgid "No" +msgstr "否" + +msgctxt "#32330" +msgid "Message" +msgstr "消息" + +msgctxt "#32331" +msgid "There was a problem while attempting to delete the media." +msgstr "尝试删除媒体时出现问题." + +msgctxt "#32332" +msgid "Home" +msgstr "主页" + +msgctxt "#32333" +msgid "Playlists" +msgstr "播放列表" + +msgctxt "#32334" +msgid "Confirm Exit" +msgstr "确定退出" + +msgctxt "#32335" +msgid "Are you ready to exit Plex?" +msgstr "你确定要退出PLEX么?" + +msgctxt "#32336" +msgid "Exit" +msgstr "退出" + +msgctxt "#32337" +msgid "Cancel" +msgstr "返回" + +msgctxt "#32338" +msgid "No Servers Found" +msgstr "没有找到服务器" + +msgctxt "#32339" +msgid "Server is not accessible" +msgstr "服务器不能访问" + +msgctxt "#32340" +msgid "Connection tests are in progress. Please wait." +msgstr "连接测试正在进行中.请耐心等待." + +msgctxt "#32341" +msgid "Server is not accessible. Please sign into your server and check your connection." +msgstr "服务器不可访问.请登录您的服务器并检查您的连接." + +msgctxt "#32342" +msgid "Switch User" +msgstr "切换用户" + +msgctxt "#32343" +msgid "Settings" +msgstr "设置" + +msgctxt "#32344" +msgid "Sign Out" +msgstr "登出" + +msgctxt "#32345" +msgid "All" +msgstr "所有" + +msgctxt "#32346" +msgid "By Name" +msgstr "按名称排序" + +msgctxt "#32347" +msgid "Artists" +msgstr "艺人" + +msgctxt "#32348" +msgid "Movies" +msgstr "电影" + +msgctxt "#32349" +msgid "photos" +msgstr "相片" + +msgctxt "#32350" +msgid "Shows" +msgstr "演出" + +msgctxt "#32351" +msgid "By Date Added" +msgstr "按添加日期排序" + +msgctxt "#32352" +msgid "Date Added" +msgstr "添加日期" + +msgctxt "#32353" +msgid "By Release Date" +msgstr "按发布日期排序" + +msgctxt "#32354" +msgid "Release Date" +msgstr "发布日期" + +msgctxt "#32355" +msgid "By Date Viewed" +msgstr "按观看日期排序" + +msgctxt "#32356" +msgid "Date Viewed" +msgstr "观看日期" + +msgctxt "#32357" +msgid "By Name" +msgstr "按名称排序" + +msgctxt "#32358" +msgid "Name" +msgstr "名称" + +msgctxt "#32359" +msgid "By Rating" +msgstr "按热度排序" + +msgctxt "#32360" +msgid "Rating" +msgstr "热度" + +msgctxt "#32361" +msgid "By Resolution" +msgstr "按分辨率排序" + +msgctxt "#32362" +msgid "Resolution" +msgstr "分辨率" + +msgctxt "#32363" +msgid "By Duration" +msgstr "按时长排序" + +msgctxt "#32364" +msgid "Duration" +msgstr "时长" + +msgctxt "#32365" +msgid "By First Aired" +msgstr "按首次播出排序" + +msgctxt "#32366" +msgid "First Aired" +msgstr "首次播出" + +msgctxt "#32367" +msgid "By Unplayed" +msgstr "按未播放排序" + +msgctxt "#32368" +msgid "Unplayed" +msgstr "未播放" + +msgctxt "#32369" +msgid "By Date Played" +msgstr "按播放日期排序" + +msgctxt "#32370" +msgid "Date Played" +msgstr "播放日期" + +msgctxt "#32371" +msgid "By Play Count" +msgstr "按播放次数排序" + +msgctxt "#32372" +msgid "Play Count" +msgstr "播放次数" + +msgctxt "#32373" +msgid "By Date Taken" +msgstr "按拍摄日期排序" + +msgctxt "#32374" +msgid "Date Taken" +msgstr "拍摄日期" + +msgctxt "#32375" +msgid "No filters available" +msgstr "没找到相关内容" + +msgctxt "#32376" +msgid "Clear Filter" +msgstr "清空内容" + +msgctxt "#32377" +msgid "Year" +msgstr "年" + +msgctxt "#32378" +msgid "Decade" +msgstr "年代" + +msgctxt "#32379" +msgid "Genre" +msgstr "类型" + +msgctxt "#32380" +msgid "Content Rating" +msgstr "内容分级" + +msgctxt "#32381" +msgid "Network" +msgstr "网络" + +msgctxt "#32382" +msgid "Collection" +msgstr "合集" + +msgctxt "#32383" +msgid "Director" +msgstr "导演" + +msgctxt "#32384" +msgid "Actor" +msgstr "演员" + +msgctxt "#32385" +msgid "Country" +msgstr "国家" + +msgctxt "#32386" +msgid "Studio" +msgstr "工作室" + +msgctxt "#32387" +msgid "Labels" +msgstr "标签" + +msgctxt "#32388" +msgid "Camera Make" +msgstr "相机品牌" + +msgctxt "#32389" +msgid "Camera Model" +msgstr "相机型号" + +msgctxt "#32390" +msgid "Aperture" +msgstr "光圈" + +msgctxt "#32391" +msgid "Shutter Speed" +msgstr "快门速度" + +msgctxt "#32392" +msgid "Lens" +msgstr "镜头" + +msgctxt "#32393" +msgid "TV Shows" +msgstr "剧集" + +msgctxt "#32394" +msgid "Music" +msgstr "音乐" + +msgctxt "#32395" +msgid "Audio" +msgstr "音频" + +msgctxt "#32396" +msgid "Subtitles" +msgstr "字幕" + +msgctxt "#32397" +msgid "Quality" +msgstr "质量" + +msgctxt "#32398" +msgid "Kodi Video Settings" +msgstr "KODI视频设置" + +msgctxt "#32399" +msgid "Kodi Audio Settings" +msgstr "KODI音频设置" + +msgctxt "#32400" +msgid "Go To Season" +msgstr "跳转到季" + +msgctxt "#32401" +msgid "Directors" +msgstr "导演" + +msgctxt "#32402" +msgid "Writer" +msgstr "编剧" + +msgctxt "#32403" +msgid "Writers" +msgstr "编剧" + +msgctxt "#32404" +msgid "Related Movies" +msgstr "相关电影" + +msgctxt "#32405" +msgid "Download Subtitles" +msgstr "下载字幕" + +msgctxt "#32406" +msgid "Subtitle Delay" +msgstr "字幕延迟" + +msgctxt "#32407" +msgid "Next Subtitle" +msgstr "下一个字幕" + +msgctxt "#32408" +msgid "Disable Subtitles" +msgstr "禁用字幕" + +msgctxt "#32409" +msgid "Enable Subtitles" +msgstr "允许字幕" + +msgctxt "#32410" +msgid "Platform Version" +msgstr "平台版本" + +msgctxt "#32411" +msgid "Unknown" +msgstr "未知" + +msgctxt "#32412" +msgid "Edit Or Clear" +msgstr "编辑或清除" + +msgctxt "#32413" +msgid "Edit IP address or clear the current setting?" +msgstr "编辑IP地址或清除当前设置?" + +msgctxt "#32414" +msgid "Clear" +msgstr "清除" + +msgctxt "#32415" +msgid "Edit" +msgstr "编辑" + +msgctxt "#32416" +msgid "Enter IP Address" +msgstr "填写IP地址" + +msgctxt "#32417" +msgid "Enter Port Number" +msgstr "填写端口" + +msgctxt "#32418" +msgid "Creator" +msgstr "创作者" + +msgctxt "#32419" +msgid "Cast" +msgstr "阵容" + +msgctxt "#32420" +msgid "Disc" +msgstr "碟片" + +msgctxt "#32421" +msgid "Sign Out" +msgstr "登出" + +msgctxt "#32422" +msgid "Exit" +msgstr "离开" + +msgctxt "#32423" +msgid "Shutdown" +msgstr "关机" + +msgctxt "#32424" +msgid "Suspend" +msgstr "暂停" + +msgctxt "#32425" +msgid "Hibernate" +msgstr "休眠" + +msgctxt "#32426" +msgid "Reboot" +msgstr "重启" + +msgctxt "#32427" +msgid "Failed" +msgstr "失败" + +msgctxt "#32428" +msgid "Login failed!" +msgstr "登陆失败!" + +msgctxt "#32429" +msgid "Resume from {0}" +msgstr "从{0}恢复" + +msgctxt "#32430" +msgid "Discovery" +msgstr "发现" + +msgctxt "#32431" +msgid "Search" +msgstr "搜索" + +msgctxt "#32432" +msgid "Space" +msgstr "空格" + +msgctxt "#32433" +msgid "Clear" +msgstr "清除" + +msgctxt "#32434" +msgid "Searching..." +msgstr "搜索中..." + +msgctxt "#32435" +msgid "No Results" +msgstr "无结果" + +msgctxt "#32436" +msgid "Paused" +msgstr "暂停" + +msgctxt "#32437" +msgid "Welcome" +msgstr "欢迎" + +msgctxt "#32438" +msgid "Previous" +msgstr "重播" + +msgctxt "#32439" +msgid "Playing Next" +msgstr "播放下一集" + +msgctxt "#32440" +msgid "On Deck" +msgstr "已播项目" + +msgctxt "#32441" +msgid "Unknown" +msgstr "未知" + +msgctxt "#32442" +msgid "Embedded" +msgstr "内嵌" + +msgctxt "#32443" +msgid "Forced" +msgstr "强制" + +msgctxt "#32444" +msgid "Lyrics" +msgstr "歌词" + +msgctxt "#32445" +msgid "Mono" +msgstr "单声道" + +msgctxt "#32446" +msgid "Stereo" +msgstr "立体声" + +msgctxt "#32447" +msgid "None" +msgstr "无" + +msgctxt "#32448" +msgid "Playback Failed!" +msgstr "播放失败!" + +msgctxt "#32449" +msgid "Can't connect to plex.tv[CR]Check your internet connection and try again." +msgstr "无法连接至plex.tv[CR]检查您的互联网连接,然后重试." + +msgctxt "#32450" +msgid "Choose Version" +msgstr "选择版本" + +msgctxt "#32451" +msgid "Play Version..." +msgstr "播放版本" + +msgctxt "#32452" +msgid "No Content available in this library" +msgstr "库里没有可播放的内容" + +msgctxt "#32453" +msgid "Please add content and/or check that 'Include in dashboard' is enabled." +msgstr "请添加内容和/或检查 '包括在仪表板' 是否已启用." + +msgctxt "#32454" +msgid "No Content available for this filter" +msgstr "没有可用于此过滤器的内容" + +msgctxt "#32455" +msgid "Please change change or remove the current filter" +msgstr "请更改或删除当前过滤器" + +msgctxt "#32456" +msgid "Show" +msgstr "节目" + +msgctxt "#32457" +msgid "By Show" +msgstr "按节目排序" + +msgctxt "#32458" +msgid "Episodes" +msgstr "集数" + +msgctxt "#32459" +msgid "Offline Mode" +msgstr "离线模式" + +msgctxt "#32460" +msgid "Sign In" +msgstr "登录" + +msgctxt "#32461" +msgid "Albums" +msgstr "专辑" + +msgctxt "#32462" +msgid "Artist" +msgstr "艺人" + +msgctxt "#32463" +msgid "By Artist" +msgstr "按艺人排序" + +msgctxt "#32464" +msgid "Player" +msgstr "播放" + +msgctxt "#32465" +msgid "Use skip step settings from Kodi" +msgstr "使用Kodi设置的跳过步骤" + +msgctxt "#32466" +msgid "Automatically seek selected position after a delay" +msgstr "延迟后自动跳到所选位置" + +msgctxt "#32467" +msgid "User Interface" +msgstr "用户界面" + +msgctxt "#32468" +msgid "Show dynamic background art" +msgstr "显示动态背景艺术" + +msgctxt "#32469" +msgid "Background art blur amount" +msgstr "背景艺术模糊量" + +msgctxt "#32470" +msgid "Background art opacity" +msgstr "背景艺术不透明度" + +msgctxt "#32471" +msgid "Use Plex/Kodi steps for timeline" +msgstr "使用Plex/Kodi的时间轴步骤" + +msgctxt "#32480" +msgid "Theme music" +msgstr "主题音乐" + +msgctxt "#32481" +msgid "Off" +msgstr "关闭" + +msgctxt "#32482" +msgid "%(percentage)s %%" +msgstr "" + +msgctxt "#32483" +msgid "Hide Stream Info" +msgstr "隐藏流信息" + +msgctxt "#32484" +msgid "Show Stream Info" +msgstr "显示流信息" + +msgctxt "#32485" +msgid "Go back instantly with the previous menu action in scrolled views" +msgstr "滚动视图中的上一个菜单操作后立即返回" + +msgctxt "#32487" +msgid "Seek Delay" +msgstr "跳过延迟" + +msgctxt "#32488" +msgid "Screensaver" +msgstr "屏幕保护" + +msgctxt "#32489" +msgid "Quiz Mode" +msgstr "测验模式" + +msgctxt "#32490" +msgid "Collections" +msgstr "合集" + +msgctxt "#32491" +msgid "Folders" +msgstr "文件夹" + +msgctxt "#32492" +msgid "Kodi Subtitle Settings" +msgstr "KODI字幕设置" + +msgctxt "#32493" +msgid "Prefer normal over forced subtitles if available (PMS selects forced by default)" +msgstr "如果有的话,优先选择正常字幕而不是强制字幕(PMS默认选择强制字幕)" + +msgctxt "#32495" +msgid "Skip intro" +msgstr "跳过片头" + +msgctxt "#32500" +msgid "Always show post-play screen (even for short videos)" +msgstr "始终显示播放后的画面(即使是短视频)" + +msgctxt "#32501" +msgid "Time-to-wait between videos on post-play" +msgstr "播放后的视频之间的等待时间" + +msgctxt "#32505" +msgid "Visit media in video playlist instead of playing it" +msgstr "访问视频播放列表中的媒体而不是播放它" + +msgctxt "#32601" +msgid "Allow AV1" +msgstr "允许AV1" + +msgctxt "#32602" +msgid "Enable this if your hardware can handle AV1. Disable it to force transcoding." +msgstr "如果你的硬件可以处理AV1,请启用此功能.禁用它可以强制进行转码." + +msgctxt "#32521" +msgid "Skip Intro Button Timeout" +msgstr "跳过片头按钮超时" + +msgctxt "#32522" +msgid "Automatically Skip Intro" +msgstr "自动跳过片头" + +msgctxt "#32523" +msgid "Automatically skip intros if available." +msgstr "如果可用既自动跳过片头." + +msgctxt "#32524" +msgid "Set how long the skip intro button shows for." +msgstr "设置跳过片头按钮的显示时间." + diff --git a/script.plexmod/resources/settings.xml b/script.plexmod/resources/settings.xml new file mode 100644 index 000000000..50da42f18 --- /dev/null +++ b/script.plexmod/resources/settings.xml @@ -0,0 +1,335 @@ + + +
+ + + + + + + + + 0 + false + + + + 0 + false + + + + 0 + true + + + + 0 + 1.0 + + 1.0 + 0.5 + 5.0 + + + false + + + + + + + + + 0 + true + + + + 0 + 1 + + 0 + 0.1 + 5 + + + false + + + true + + + + 0 + false + + + + 0 + false + + + + 0 + true + + + + 0 + true + + + true + + + + 0 + true + + + + 0 + 120 + + 0 + 1 + 600 + + + false + + + + 0 + 10 + + 0 + 1 + 600 + + + false + + + + + + + + + 0 + 2.5 + + -10 + 0.5 + 10 + + + 33630 + false + + + + 0 + 10 + + 1 + 1 + 40 + + + 32524 + false + + + + 0 + 10 + + 1 + 1 + 40 + + + 32528 + false + + + + 0 + 60 + + 10 + 10 + 1800 + + + 33510 + false + + + + 0 + true + + + + 0 + false + + + + + + + + + 0 + false + + + + 0 + false + + + + 0 + true + + + + 0 + true + + + true + + + + 0 + - + + + false + + + + 0 + 0 + + 0 + 1 + 256 + + + false + + + true + + + + 0 + 20 + + 0 + 1 + 100 + + + false + + + true + + + + 0 + false + + + true + + + + 0 + true + + + + 0 + true + + + + 0 + 16 + + 32501 + + + + 0 + true + + + + 0 + true + + + + + + + + 0 + false + + + + + + + + 0 + 5 + + 0.1 + 0.1 + 20 + + + false + + + + 0 + 2.5 + + 0.1 + 0.1 + 20 + + + false + + + + 0 + 10 + + 1 + 1 + 1000 + + + false + + + + +
+
diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-album.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-album.xml new file mode 100644 index 000000000..e9c0d979a --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-album.xml @@ -0,0 +1,628 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + 0 + 135 + 101 + + + 60 + 0 + + 0 + 5 + 420 + 40 + font13 + left + center + FFFFFFFF + + + + 0 + 60 + 420 + 40 + font13 + left + center + FFFFFFFF + + + + 0 + 142 + 630 + 630 + $INFO[Window.Property(album.thumb)] + scale + + + + + VisibleChange + 301 + 50 + 784 + 650 + 145 + 200 + 101 + -50 + horizontal + center + 200 + true + + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 750 + 0 + 1170 + 945 + + 0 + 0 + 1380 + 945 + script.plex/white-square.png + 20000000 + + + + 0 + 0 + 1170 + 945 + 200 + 152 + 300 + 200 + vertical + 4 + 152 + + + + String.IsEmpty(ListItem.Property(is.header)) + 120 + 24 + + !String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + -10 + 0 + 60 + 76 + font10 + center + center + D8FFFFFF + + + + String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 2 + 21 + 35 + 35 + script.plex/indicators/playing-circle.png + FFE5A00D + + + 90 + 0 + + 0 + 0 + 723 + 76 + font10 + left + center + FFFFFFFF + + + + + 730 + 0 + 200 + 76 + font10 + right + center + D8FFFFFF + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 73 + 930 + 2 + script.plex/white-square.png + 40000000 + + + + + !String.IsEmpty(ListItem.Property(is.header)) + 120 + 24 + 400 + 76 + font10 + left + center + FFFFFFFF + + + + + + + + + String.IsEmpty(ListItem.Property(is.header)) + + !Control.HasFocus(101) + 120 + 24 + + !String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + -10 + 0 + 60 + 76 + font10 + center + center + D8FFFFFF + + + + String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 2 + 21 + 35 + 35 + script.plex/indicators/playing-circle.png + FFE5A00D + + + 90 + 0 + + 0 + 0 + 723 + 76 + font10 + left + center + FFFFFFFF + + + + + 730 + 0 + 200 + 76 + font10 + right + center + D8FFFFFF + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 73 + 930 + 2 + script.plex/white-square.png + 40000000 + + + + + Control.HasFocus(101) + 63 + 21 + + -40 + -40 + 1130 + 156 + script.plex/square-rounded-shadow.png + + + 0 + 0 + 1044 + 76 + script.plex/white-square-rounded.png + FFE5A00D + + + !String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 48 + 0 + 50 + 76 + font12 + left + center + B8000000 + + + + String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 36 + 21 + 35 + 35 + script.plex/indicators/playing-circle.png + FF000000 + + + 140 + 0 + + 0 + 0 + 738 + 76 + font12 + left + center + DF000000 + + + + + 796 + 0 + 200 + 76 + font12 + right + center + B8000000 + + + + + + + !String.IsEmpty(ListItem.Property(is.header)) + 120 + 24 + 400 + 76 + font10 + left + center + FFFFFFFF + + + + + + + + 1128 + 33 + 10 + 874 + 101 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + + + + + 201 + 0 + 0 + 1920 + 135 + + ControlGroup(200).HasFocus(0) + false + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-artist.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-artist.xml new file mode 100644 index 000000000..797137b02 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-artist.xml @@ -0,0 +1,527 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + 0 + 135 + 101 + + + VisibleChange + 301 + 594 + 418 + 600 + 145 + 200 + 101 + -50 + horizontal + center + 200 + true + + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/info-focus.png + script.plex/buttons/info.png + + + + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + + 60 + 0 + 1920 + 200 + + 0 + 0 + 519 + 519 + $INFO[Window.Property(thumb)] + scale + + + 579 + 5 + 1190 + 40 + font13 + left + center + FFFFFFFF + + + + 579 + 55 + 1190 + 40 + font13 + left + center + FFDDDDDD + + + + 579 + 158 + 1221 + 250 + font13 + left + FFDDDDDD + + 152 + + + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 0 + 585 + 1920 + 360 + + 0 + 0 + 1920 + 360 + script.plex/white-square.png + 20000000 + + + 0 + -20 + 1920 + 700 + 300 + 200 + horizontal + 2 + + + + 60 + 60 + + 0 + 0 + + 0 + 0 + 215 + 215 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 215 + 215 + $INFO[ListItem.Thumb] + scale + + + 0 + 220 + + 0 + 0 + 215 + 30 + font10 + center + FFFFFFFF + + + + 0 + 30 + 215 + 30 + font10 + center + FFFFFFFF + + + + + + + + + + + 60 + 60 + + Focus + UnFocus + + 0 + 0 + + Control.HasFocus(101) + -40 + -40 + 295 + 295 + script.plex/square-rounded-shadow.png + + + 0 + 0 + 215 + 215 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 215 + 215 + $INFO[ListItem.Thumb] + scale + + + 0 + 220 + + false + 0 + 0 + 215 + 30 + font10 + center + FFFFFFFF + + + + 0 + 30 + 215 + 30 + font10 + center + FFFFFFFF + + + + + + Control.HasFocus(101) + -5 + -5 + 225 + 225 + script.plex/home/selected.png + + + + + + + + + + + 201 + 0 + 0 + 1920 + 135 + + VisibleChange + ControlGroup(200).HasFocus(0) + false + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-background.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-background.xml new file mode 100644 index 000000000..7d28bf2fb --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-background.xml @@ -0,0 +1,65 @@ + + + 100 + + 1 + 0 + 0 + + 0xff111111 + + + !String.IsEmpty(Window(10000).Property(script.plex.background.splash)) + 710 + 459 + 500 + 162 + script.plex/splash.png + + + + !String.IsEmpty(Window(10000).Property(script.plex.background.busy)) + + 812 + 135 + 300 + 97 + script.plex/user_select/plex.png + + + 840 + 465 + 240 + 150 + script.plex/busy-back.png + A0FFFFFF + + + 915 + 521 + 90 + 38 + script.plex/busy.gif + + + + + !String.IsEmpty(Window(10000).Property(script.plex.background.shutdown)) + + 840 + 465 + 240 + 150 + script.plex/busy-back.png + A0FFFFFF + + + 915 + 521 + 90 + 38 + script.plex/busy.gif + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-blank.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-blank.xml new file mode 100644 index 000000000..2f887866c --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-blank.xml @@ -0,0 +1,12 @@ + + + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-busy.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-busy.xml new file mode 100644 index 000000000..31b167f2b --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-busy.xml @@ -0,0 +1,28 @@ + + + + 1 + 0 + 0 + + + + WindowOpen + + 840 + 465 + 240 + 150 + script.plex/busy-back.png + A0FFFFFF + + + 915 + 521 + 90 + 38 + script.plex/busy.gif + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-busy_msg.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-busy_msg.xml new file mode 100644 index 000000000..5fc2a6bb5 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-busy_msg.xml @@ -0,0 +1,35 @@ + + + + 1 + 0 + 0 + + + + 0 + 0 + WindowOpen + + 840 + 465 + 240 + 150 + script.plex/busy-back.png + A0FFFFFF + + + 840 + 465 + 240 + 150 + center + center + FFFFFFFF + + font14 + black + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown.xml new file mode 100644 index 000000000..8ace797b8 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown.xml @@ -0,0 +1,201 @@ + + SetProperty(dropdown,1) + 100 + + 1 + 0 + 0 + + 100 + + + + 0 + 0 + 1920 + 1080 + - + - + + + 250 + !String.IsEmpty(Window.Property(show)) + 0 + 0 + + -40 + -40 + 380 + 146 + script.plex/drop-shadow.png + + + 0 + 0 + 300 + 924 + Close + noop + Close + Close + Close + noop + 200 + vertical + + + + !String.IsEmpty(ListItem.Property(first)) + 0 + 0 + 300 + 66 + script.plex/white-square-top-rounded.png + + + String.IsEmpty(ListItem.Property(first)) + String.IsEmpty(ListItem.Property(last)) + String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 300 + 66 + script.plex/white-square.png + + + !String.IsEmpty(ListItem.Property(last)) + 0 + 0 + 300 + 66 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 300 + 66 + script.plex/white-square-rounded.png + + + String.IsEmpty(ListItem.Property(with.indicator)) + 0 + 0 + 300 + 66 + font12 + center + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(with.indicator)) + + 20 + 0 + 280 + 66 + font12 + left + center + FFFFFFFF + + + + 254 + 20 + 26 + 26 + $INFO[ListItem.Thumb] + keep + + + + !String.IsEmpty(ListItem.Property(separator)) + 0 + 64 + 300 + 2 + script.plex/white-square.png + + + + + !String.IsEmpty(ListItem.Property(first)) + 0 + 0 + 300 + 66 + script.plex/white-square-top-rounded.png + + + String.IsEmpty(ListItem.Property(first)) + String.IsEmpty(ListItem.Property(last)) + String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 300 + 66 + script.plex/white-square.png + + + !String.IsEmpty(ListItem.Property(last)) + 0 + 0 + 300 + 66 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 300 + 66 + script.plex/white-square-rounded.png + + + String.IsEmpty(ListItem.Property(with.indicator)) + 0 + 0 + 300 + 66 + font12 + center + center + FF000000 + + + + !String.IsEmpty(ListItem.Property(with.indicator)) + + 20 + 0 + 280 + 66 + font12 + left + center + FF000000 + + + + 254 + 20 + 26 + 26 + $INFO[ListItem.Thumb] + keep + + + + !String.IsEmpty(ListItem.Property(separator)) + 0 + 64 + 300 + 2 + script.plex/white-square.png + + + + + + \ No newline at end of file diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown_header.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown_header.xml new file mode 100644 index 000000000..3c623d851 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-dropdown_header.xml @@ -0,0 +1,247 @@ + + SetProperty(dropdown,1) + 100 + + 1 + 0 + 0 + + + + + 0 + 0 + 1920 + 1080 + - + - + + + 250 + !String.IsEmpty(Window.Property(show)) + 0 + 0 + + -60 + -106 + 720 + 146 + script.plex/drop-shadow.png + + + !String.IsEmpty(Window.Property(header)) + -20 + -66 + + 0 + 0 + 640 + 132 + script.plex/white-square-rounded.png + + + 20 + 0 + 600 + 66 + font12 + center + center + FFFFFFFF + + + + + 0 + 0 + 600 + 924 + Close + noop + Close + Close + Close + noop + 200 + vertical + + + + !String.IsEmpty(ListItem.Property(first)) + 0 + 0 + 600 + 66 + script.plex/white-square-top-rounded.png + + + String.IsEmpty(ListItem.Property(first)) + String.IsEmpty(ListItem.Property(last)) + String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 600 + 66 + script.plex/white-square.png + + + !String.IsEmpty(ListItem.Property(last)) + 0 + 0 + 600 + 66 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 600 + 66 + script.plex/white-square-rounded.png + + + String.IsEmpty(ListItem.Property(with.indicator)) + String.IsEqual(ListItem.Property(align),center) + 0 + 0 + 600 + 66 + font12 + center + center + FFFFFFFF + + + + String.IsEmpty(ListItem.Property(with.indicator)) + String.IsEqual(ListItem.Property(align),left) + 20 + 0 + 600 + 66 + font12 + left + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(with.indicator)) + + 60 + 0 + 520 + 66 + font12 + left + center + FFFFFFFF + + + + 20 + 20 + 26 + 26 + $INFO[ListItem.Thumb] + keep + + + + !String.IsEmpty(ListItem.Property(separator)) + 0 + 64 + 600 + 2 + script.plex/white-square.png + + + + + !String.IsEmpty(ListItem.Property(first)) + 0 + 0 + 600 + 66 + script.plex/white-square-top-rounded.png + + + String.IsEmpty(ListItem.Property(first)) + String.IsEmpty(ListItem.Property(last)) + String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 600 + 66 + script.plex/white-square.png + + + !String.IsEmpty(ListItem.Property(last)) + 0 + 0 + 600 + 66 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 600 + 66 + script.plex/white-square-rounded.png + + + String.IsEmpty(ListItem.Property(with.indicator)) + String.IsEqual(ListItem.Property(align),center) + 0 + 0 + 600 + 66 + font12 + center + center + FF000000 + + + + String.IsEmpty(ListItem.Property(with.indicator)) + String.IsEqual(ListItem.Property(align),left) + 20 + 0 + 600 + 66 + font12 + left + center + FF000000 + + + + !String.IsEmpty(ListItem.Property(with.indicator)) + + 60 + 0 + 520 + 66 + font12 + left + center + FF000000 + + + + 20 + 20 + 26 + 26 + $INFO[ListItem.Thumb] + keep + + + + !String.IsEmpty(ListItem.Property(separator)) + 0 + 64 + 600 + 2 + script.plex/white-square.png + + + + + + \ No newline at end of file diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-episodes.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-episodes.xml new file mode 100644 index 000000000..47428c701 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-episodes.xml @@ -0,0 +1,1820 @@ + + + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + Conditional + + + + + + + + + + + + + + + + + + 0 + 155 + 101 + + + VisibleChange + !String.IsEmpty(Window.Property(initialized)) + 301 + 30 + 405 + 717 + 200 + 200 + 400 + center + -50 + horizontal + 200 + true + + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/info-focus.png + script.plex/buttons/info.png + + + + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + !String.IsEmpty(Container(400).ListItem.Property(media.multiple)) + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/media-focus.png + script.plex/buttons/media.png + + + + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/settings-focus.png + script.plex/buttons/settings.png + + + + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + + 0 + 0 + 1920 + 600 + + + 60 + 0 + 657 + 393 + script.plex/thumb_fallbacks/show.png + WindowOpen + scale + + + 60 + 0 + 657 + 393 + $INFO[Container(400).ListItem.Thumb] + scale + + + !String.IsEmpty(Container(400).ListItem.Property(unwatched)) + 682 + 0 + 35 + 35 + script.plex/indicators/unwatched.png + + + + + 776 + 0 + 1360 + 60 + left + 0 + horizontal + true + + auto + 60 + font13 + left + top + FFFFFFFF + + + + !String.IsEmpty(Container(400).ListItem.Property(remainingTime)) + 10 + 6 + auto + 34 + font12 + center + center + FFE5A00D + FFE5A00D + 15 + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + + + + + 776 + 50 + 1360 + 60 + font13 + left + top + FFFFFFFF + + + + 776 + 140 + 1360 + 30 + left + 0 + horizontal + true + + auto + 30 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Container(400).ListItem.Property(unavailable)) + 10 + auto + 30 + font12 + center + center + FFFFFFFF + FFFFFFFF + 15 + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + + + + + + 776 + 188 + 1360 + 34 + left + 0 + horizontal + true + + auto + 34 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Container(400).ListItem.Property(rating.stars)) + auto + 34 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Container(400).ListItem.Property(rating.stars)) + 6 + 134 + 22 + script.plex/stars/$INFO[Container(400).ListItem.Property(rating.stars)].png + + + !String.IsEmpty(Container(400).ListItem.Property(video.res)) + 10 + auto + 34 + font12 + center + center + FFFFFFFF + FFFFFFFF + 15 + script.plex/white-square-rounded-top-padded.png + script.plex/white-square-rounded-top-padded.png + + + + + + !String.IsEmpty(Container(400).ListItem.Property(rating)) + 1560 + 140 + 300 + 32 + right + 15 + horizontal + true + + 2 + 63 + 30 + $INFO[Container(400).ListItem.Property(rating.image)] + keep + + + auto + 30 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Container(400).ListItem.Property(rating2)) + 2 + 40 + 30 + $INFO[Container(400).ListItem.Property(rating2.image)] + keep + + + !String.IsEmpty(Container(400).ListItem.Property(rating2)) + auto + 30 + font12 + left + FFFFFFFF + + + + + + 776 + 263 + 1360 + 34 + left + 15 + horizontal + true + + !String.IsEmpty(Container(400).ListItem.Property(audio)) + auto + 34 + font12 + center + center + FFFFFFFF + FFFFFFFF + 15 + script.plex/white-square-rounded-top-padded.png + script.plex/white-square-rounded-top-padded.png + + + + auto + 34 + font12 + left + center + FFFFFFFF + + + + !String.IsEmpty(Container(400).ListItem.Property(subtitles)) + 34 + auto + 34 + font12 + center + center + FFFFFFFF + FFFFFFFF + 15 + script.plex/white-square-rounded-top-padded.png + script.plex/white-square-rounded-top-padded.png + + + + !String.IsEmpty(Container(400).ListItem.Property(subtitles)) + auto + 34 + font12 + left + center + FFFFFFFF + + + + + 776 + 340 + 1084 + 172 + font12 + left + FFFFFFFF + + + + + WindowOpen + -1 + 557 + 1 + 8 + script.plex/white-square.png + FFCC7B19 + + + !Control.IsVisible(500) + 0 + 565 + 1920 + 2 + script.plex/white-square.png + A0000000 + + + + + !String.IsEmpty(Window.Property(initialized)) + 0 + 565 + 1920 + 1800 + + 300 + 0 + + + Integer.IsGreater(Container(400).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 360 + 1920 + + 0 + 0 + 1920 + 360 + script.plex/white-square.png + 40000000 + + + 60 + 0 + 800 + 80 + font12 + left + center + AAFFFFFF + + + + 0 + 18 + 1920 + 430 + 300 + 401 + false + false + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 158 + + 0 + 0 + 299 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 299 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 264 + 0 + 35 + 35 + script.plex/indicators/unwatched.png + + + false + 0 + 175 + 299 + 60 + font12 + center + AAFFFFFF + + + + false + 0 + 210 + 299 + 60 + font12 + center + AAFFFFFF + + + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 299 + 168 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 119 + 34 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 119 + 34 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 85.5 + 20 + 128 + 128 + script.plex/home/busy.gif + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(400) + -40 + -40 + 389 + 258 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 158 + + 0 + 0 + 299 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 299 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 264 + 0 + 35 + 35 + script.plex/indicators/unwatched.png + + + Control.HasFocus(400) + + true + 0 + 175 + 299 + 60 + font12 + center + AAFFFFFF + + + + true + 0 + 210 + 299 + 60 + font12 + center + AAFFFFFF + + + + + !Control.HasFocus(400) + + false + 0 + 175 + 299 + 60 + font12 + center + FFCC7B19 + + + + false + 0 + 210 + 299 + 60 + font12 + center + FFCC7B19 + + + + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 299 + 168 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 119 + 34 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 119 + 34 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 85.5 + 20 + 128 + 128 + script.plex/home/busy.gif + + + + + Control.HasFocus(400) + 0 + 0 + 309 + 178 + script.plex/home/selected.png + + + + + + + + + + Integer.IsGreater(Container(401).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 400 + 1920 + + 60 + 20 + 800 + 40 + font12 + left + center + FFFFFFFF + + + + 0 + 50 + 1920 + 360 + 400 + 402 + 200 + horizontal + 4 + + + + 55 + 29 + + 5 + 5 + + 0 + 0 + 158 + 236 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 158 + 236 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 113 + 0 + 45 + 40 + script.plex/white-square.png + FF000000 + + + 114 + 0 + 44 + 39 + script.plex/white-square.png + FFCC7B19 + + + 114 + 0 + 44 + 39 + font10 + center + center + FF000000 + + + + + false + 0 + 240 + 158 + 54 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 29 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(401) + -40 + -40 + 248 + 326 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 158 + 236 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 158 + 236 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 113 + 0 + 45 + 40 + script.plex/white-square.png + FF000000 + + + 114 + 0 + 44 + 39 + script.plex/white-square.png + FFCC7B19 + + + 114 + 0 + 44 + 39 + font10 + center + center + FF000000 + + + + + Control.HasFocus(401) + 0 + 240 + 158 + 54 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(401) + 0 + 0 + 168 + 246 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(402).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 360 + 1920 + + !String.IsEmpty(Window.Property(divider.402)) + 60 + 0 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 800 + 80 + font12 + left + center + AAFFFFFF + + + + 0 + 18 + 1920 + 430 + 401 + 403 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 175 + 299 + 60 + font10 + center + AAFFFFFF + + + + false + 0 + 205 + 299 + 60 + font10 + center + AAFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(402) + -40 + -40 + 389 + 258 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + + Control.HasFocus(402) + 0 + 175 + 299 + 60 + font10 + center + AAFFFFFF + + + + Control.HasFocus(402) + 0 + 205 + 299 + 60 + font10 + center + AAFFFFFF + + + + + + Control.HasFocus(402) + 0 + 0 + 309 + 178 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(403).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 402 + 1920 + 520 + + !String.IsEmpty(Window.Property(divider.403)) + 60 + 0 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 16 + 1920 + 520 + 402 + 404 + false + false + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 369 + 244 + 38 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + Control.HasFocus(403) + -40 + -40 + 324 + 441 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + Control.HasFocus(403) + 0 + 369 + 244 + 38 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(403) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(404).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 403 + 1920 + 410 + + !String.IsEmpty(Window.Property(divider.404)) + 60 + 20 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 20 + 1000 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 36 + 1920 + 410 + 403 + 404 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + script.plex/thumb_fallbacks/role.png + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + 0 + 253 + + false + 0 + 0 + 244 + 60 + font10 + center + AAFFFFFF + + + + false + 0 + 30 + 244 + 60 + font10 + center + AAFFFFFF + + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(404) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + script.plex/thumb_fallbacks/role.png + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + 0 + 253 + + Control.HasFocus(404) + 0 + 0 + 244 + 60 + font10 + center + AAFFFFFF + + + + Control.HasFocus(404) + 0 + 30 + 244 + 60 + font10 + center + AAFFFFFF + + + + + + Control.HasFocus(404) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + + + Conditional + 201 + 0 + 0 + 1920 + 135 + + VisibleChange + ControlGroup(200).HasFocus(0) + !String.IsEmpty(Window.Property(on.extras)) + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-home.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-home.xml new file mode 100644 index 000000000..e5431162c --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-home.xml @@ -0,0 +1,8622 @@ + + + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + Conditional + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 101 + 0 + 96 + 2130 + 5540 + 20 + vertical + true + 200 + + 2130 + 200 + + Conditional + Conditional + -300 + 0 + 2430 + 240 + 101 + 203 + 204 + 400 + 200 + horizontal + 4 + 3 + 102 + + + + !String.IsEmpty(ListItem.Property(item)) + 60 + 40 + 298 + 200 + + 0 + 0 + 238 + 117 + + String.IsEmpty(ListItem.Property(is.home)) + 0 + 0 + 238 + 117 + script.plex/white-square-rounded.png + FF1F1F1F + + + + String.IsEmpty(ListItem.Property(is.home)) + 84 + 23.5 + 70 + 70 + $INFO[ListItem.Thumb] + keep + + + !String.IsEmpty(ListItem.Property(is.home)) + 74 + 13.5 + 90 + 90 + script.plex/home/type/home.png + keep + + + + + false + 0 + 125 + 238 + 35 + font10 + center + FFFFFFFF + + + + + + + + + !String.IsEmpty(ListItem.Property(item)) + 60 + 40 + + Focus + UnFocus + 0 + 0 + + String.IsEmpty(ListItem.Property(is.home)) + + Control.HasFocus(101) + + -40 + -40 + 318 + 197 + script.plex/drop-shadow.png + + + UnFocus + 0 + 0 + 238 + 117 + script.plex/white-square-rounded.png + FF1F1F1F + + + UnFocus + 0 + 0 + 238 + 117 + script.plex/white-square-rounded.png + FFE5A00D + + + + !Control.HasFocus(101) + 0 + 0 + 238 + 117 + script.plex/white-square-rounded.png + FFCC7B19 + + + 84 + 23.5 + 70 + 70 + $INFO[ListItem.Thumb] + keep + + + + !String.IsEmpty(ListItem.Property(is.home)) + 74 + 13.5 + + 0 + 0 + 90 + 90 + script.plex/home/type/home.png + keep + + + Control.HasFocus(101) + UnFocus + -40 + -40 + 170 + 170 + script.plex/home/type/home-selected.png + keep + + + + Control.HasFocus(100) + 0 + 125 + 238 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + Integer.IsGreater(Container(400).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1920 + 440 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1920 + 440 + 101 + 401 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.400)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(400) + -40 + -40 + 622 + 389 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + Control.HasFocus(400) + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + Control.HasFocus(400) + !String.IsEmpty(Window.Property(hub.text2lines.400)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(400) + 0 + 0 + 542 + 309 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(400).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.400)) + 10 + 12 + + + + Integer.IsGreater(Container(401).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 401 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 400 + 402 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.401)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(401) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(401) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(401) + !String.IsEmpty(Window.Property(hub.text2lines.401)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(401) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(401).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.401)) + 10 + 7 + + + + Integer.IsGreater(Container(402).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 402 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 401 + 403 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.402)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(402) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(402) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(402) + !String.IsEmpty(Window.Property(hub.text2lines.402)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(402) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(402).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.402)) + 10 + 7 + + + + Integer.IsGreater(Container(403).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 403 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 402 + 404 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.403)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(403) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(403) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(403) + !String.IsEmpty(Window.Property(hub.text2lines.403)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(403) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(403).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.403)) + 10 + 7 + + + + Integer.IsGreater(Container(404).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 404 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 403 + 405 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.404)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(404) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(404) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(404) + !String.IsEmpty(Window.Property(hub.text2lines.404)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(404) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(404).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.404)) + 10 + 7 + + + + Integer.IsGreater(Container(405).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 405 + 1920 + 392 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 392 + 404 + 406 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.405)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(405) + -40 + -40 + 324 + 324 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(405) + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(405) + !String.IsEmpty(Window.Property(hub.text2lines.405)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(405) + 0 + 0 + 254 + 254 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(405).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.405)) + 10 + 7 + + + + Integer.IsGreater(Container(406).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1920 + 440 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1920 + 440 + 405 + 407 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.406)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(406) + -40 + -40 + 622 + 389 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + Control.HasFocus(406) + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + Control.HasFocus(406) + !String.IsEmpty(Window.Property(hub.text2lines.406)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(406) + 0 + 0 + 542 + 309 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(406).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.406)) + 10 + 12 + + + + Integer.IsGreater(Container(407).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 407 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 406 + 408 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.407)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(407) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(407) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(407) + !String.IsEmpty(Window.Property(hub.text2lines.407)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(407) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(407).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.407)) + 10 + 7 + + + + Integer.IsGreater(Container(408).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 408 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 407 + 409 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.408)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(408) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(408) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(408) + !String.IsEmpty(Window.Property(hub.text2lines.408)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(408) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(408).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.408)) + 10 + 7 + + + + Integer.IsGreater(Container(409).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 409 + 1920 + 392 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 392 + 408 + 410 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.409)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(409) + -40 + -40 + 324 + 324 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(409) + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(409) + !String.IsEmpty(Window.Property(hub.text2lines.409)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(409) + 0 + 0 + 254 + 254 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(409).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.409)) + 10 + 7 + + + + Integer.IsGreater(Container(410).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 410 + 1920 + 392 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 392 + 409 + 411 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.410)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(410) + -40 + -40 + 324 + 324 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(410) + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(410) + !String.IsEmpty(Window.Property(hub.text2lines.410)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(410) + 0 + 0 + 254 + 254 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(410).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.410)) + 10 + 7 + + + + Integer.IsGreater(Container(411).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 411 + 1920 + 392 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 392 + 410 + 412 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.411)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(411) + -40 + -40 + 324 + 324 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(411) + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(411) + !String.IsEmpty(Window.Property(hub.text2lines.411)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(411) + 0 + 0 + 254 + 254 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(411).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.411)) + 10 + 7 + + + + Integer.IsGreater(Container(412).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 412 + 1920 + 392 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 392 + 411 + 420 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.412)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(412) + -40 + -40 + 324 + 324 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(412) + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(412) + !String.IsEmpty(Window.Property(hub.text2lines.412)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(412) + 0 + 0 + 254 + 254 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(412).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.412)) + 10 + 7 + + + + Integer.IsGreater(Container(420).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 420 + 1920 + 392 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 392 + 412 + 421 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.420)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(420) + -40 + -40 + 324 + 324 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(420) + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(420) + !String.IsEmpty(Window.Property(hub.text2lines.420)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(420) + 0 + 0 + 254 + 254 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(420).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.420)) + 10 + 7 + + + + Integer.IsGreater(Container(421).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 421 + 1920 + 392 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 392 + 420 + 422 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.421)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(421) + -40 + -40 + 324 + 324 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(421) + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(421) + !String.IsEmpty(Window.Property(hub.text2lines.421)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(421) + 0 + 0 + 254 + 254 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(421).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.421)) + 10 + 7 + + + + Integer.IsGreater(Container(422).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 422 + 1920 + 392 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 392 + 421 + 413 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.422)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(422) + -40 + -40 + 324 + 324 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 244 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 72 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 58 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 234 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(422) + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(422) + !String.IsEmpty(Window.Property(hub.text2lines.422)) + 0 + 281 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(422) + 0 + 0 + 254 + 254 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(422).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.422)) + 10 + 7 + + + + Integer.IsGreater(Container(413).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 413 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 422 + 414 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.413)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(413) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(413) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(413) + !String.IsEmpty(Window.Property(hub.text2lines.413)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(413) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(413).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.413)) + 10 + 7 + + + + Integer.IsGreater(Container(414).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 414 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 413 + 415 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.414)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(414) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(414) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(414) + !String.IsEmpty(Window.Property(hub.text2lines.414)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(414) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(414).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.414)) + 10 + 7 + + + + Integer.IsGreater(Container(415).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 415 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 414 + 416 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.415)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(415) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(415) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(415) + !String.IsEmpty(Window.Property(hub.text2lines.415)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(415) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(415).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.415)) + 10 + 7 + + + + Integer.IsGreater(Container(416).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 416 + 1920 + 515 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 29 + 1920 + 515 + 415 + 417 + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + false + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.416)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(416) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + Control.HasFocus(416) + 0 + 371 + 244 + 35 + font10 + center + FFFFFFFF + + + + Control.HasFocus(416) + !String.IsEmpty(Window.Property(hub.text2lines.416)) + 0 + 398 + 244 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + Control.HasFocus(416) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(416).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.416)) + 10 + 7 + + + + Integer.IsGreater(Container(417).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1920 + 440 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1920 + 440 + 416 + 418 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.417)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(417) + -40 + -40 + 622 + 389 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + Control.HasFocus(417) + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + Control.HasFocus(417) + !String.IsEmpty(Window.Property(hub.text2lines.417)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(417) + 0 + 0 + 542 + 309 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(417).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.417)) + 10 + 12 + + + + Integer.IsGreater(Container(418).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1920 + 440 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1920 + 440 + 417 + 419 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.418)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(418) + -40 + -40 + 622 + 389 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + Control.HasFocus(418) + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + Control.HasFocus(418) + !String.IsEmpty(Window.Property(hub.text2lines.418)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(418) + 0 + 0 + 542 + 309 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(418).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.418)) + 10 + 12 + + + + Integer.IsGreater(Container(419).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1920 + 440 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1920 + 440 + 418 + 423 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.419)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(419) + -40 + -40 + 622 + 389 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + Control.HasFocus(419) + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + Control.HasFocus(419) + !String.IsEmpty(Window.Property(hub.text2lines.419)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(419) + 0 + 0 + 542 + 309 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(419).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.419)) + 10 + 12 + + + + Integer.IsGreater(Container(423).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1920 + 440 + + 60 + 12 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1920 + 440 + 419 + 423 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + false + !String.IsEmpty(Window.Property(hub.text2lines.423)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(423) + -40 + -40 + 622 + 389 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.end)) + + 0 + 0 + 532 + 299 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + 235.5 + 99.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 202 + 85.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 532 + 299 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 289 + + 0 + 0 + 532 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 532 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 484 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 481 + 0 + + 0 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 1 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 1 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + Control.HasFocus(423) + 0 + 309 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + Control.HasFocus(423) + !String.IsEmpty(Window.Property(hub.text2lines.423)) + 0 + 337 + 532 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(423) + 0 + 0 + 542 + 309 + script.plex/home/selected.png + + + + + + + + Integer.IsGreater(Container(423).NumItems,0) + !String.IsEmpty(Window.Property(hub.text2lines.423)) + 10 + 12 + + + + + 1920 + 100 + font12 + left + center + 00FFFFFF + + + + + + + + Conditional + 201 + 0 + 0 + 1920 + 135 + + VisibleChange + ControlGroup(200).HasFocus(0) + !String.IsEmpty(Window(10000).Property(script.plex.off.sections)) + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + String.IsEmpty(Window.Property(search.dialog)) + + Focus + UnFocus + 60 + 47.5 + 40 + 40 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + 160 + 35 + 500 + 65 + font12 + left + center + FFFFFFFF + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 203 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + 576 + 34 + 1000 + 1046 + + 0 + 0 + 1000 + 1046 + 50 + 204 + right + 0 + horizontal + 200 + true + + auto + 66 + font12 + FFFFFFFF + FF000000 + FFFFFFFF + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + SetFocus(260) + + + + -93 + 93 + 66 + + 6 + 14 + 40 + 39 + $INFO[Window.Property(server.icon)] + + + 0 + 38 + 16 + 15 + $INFO[Window.Property(server.iconmod)] + + + + !String.IsEmpty(Window.Property(server.iconmod)) + 0 + 20 + 16 + 14 + $INFO[Window.Property(server.iconmod2)] + FFEEEEEE + + + + String.IsEmpty(Window.Property(server.iconmod)) + 0 + 38 + 16 + 14 + $INFO[Window.Property(server.iconmod2)] + FFEEEEEE + + + !Control.HasFocus(201) + 59 + 27 + 15 + 13 + script.plex/indicators/dropdown-triangle.png + 99FFFFFF + + + Control.HasFocus(201) + 59 + 27 + 15 + 13 + script.plex/indicators/dropdown-triangle.png + FF222222 + + + Control.HasFocus(260) | !String.IsEmpty(Window.Property(show.servers)) + -413 + 70 + + -40 + -40 + 580 + 146 + script.plex/drop-shadow.png + + + 432 + -13 + 15 + 13 + script.plex/indicators/dropdown-triangle.png + FF1F1F1F + + + + 0 + 0 + 500 + 900 + 201 + 203 + 202 + SetProperty(show.servers,) + 200 + vertical + 261 + + + + !String.IsEmpty(ListItem.Property(first)) + 0 + 0 + 500 + 100 + script.plex/white-square-top-rounded.png + + + String.IsEmpty(ListItem.Property(first)) + String.IsEmpty(ListItem.Property(last)) + String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 500 + 100 + script.plex/white-square.png + + + !String.IsEmpty(ListItem.Property(last)) + 0 + 0 + 500 + 100 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 500 + 100 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Label2) + + 20 + 20 + 400 + 35 + font12 + left + center + FFFFFFFF + + + + 20 + 50 + 400 + 35 + font12 + left + center + FFA0A0A0 + + + + + String.IsEmpty(ListItem.Label2) + 20 + 0 + 400 + 100 + font12 + left + center + FFFFFFFF + + + + + + String.IsEmpty(ListItem.Property(status)) + String.IsEmpty(ListItem.Property(current)) + !String.IsEmpty(ListItem.Property(local)) + 456 + 38 + 24 + 21 + script.plex/home/device/home.png + + + + String.IsEmpty(ListItem.Property(status)) + !String.IsEmpty(ListItem.Property(current)) + !String.IsEmpty(ListItem.Property(local)) + 415 + 38 + 24 + 21 + script.plex/home/device/home.png + + + + !String.IsEmpty(ListItem.Property(status)) + String.IsEmpty(ListItem.Property(current)) + !String.IsEmpty(ListItem.Property(local)) + 415 + 38 + 24 + 21 + script.plex/home/device/home.png + + + + !String.IsEmpty(ListItem.Property(status)) + !String.IsEmpty(ListItem.Property(current)) + !String.IsEmpty(ListItem.Property(local)) + 374 + 38 + 24 + 21 + script.plex/home/device/home.png + + + + !String.IsEmpty(ListItem.Property(status)) + String.IsEmpty(ListItem.Property(current)) + 456 + 38 + 24 + 24 + script.plex/home/device/$INFO[ListItem.Property(status)] + + + + !String.IsEmpty(ListItem.Property(status)) + !String.IsEmpty(ListItem.Property(current)) + 415 + 38 + 24 + 24 + script.plex/home/device/$INFO[ListItem.Property(status)] + + + !String.IsEmpty(ListItem.Property(current)) + 449 + 38 + 31 + 24 + script.plex/home/device/check.png + + + + + + !String.IsEmpty(ListItem.Property(first)) + 0 + 0 + 500 + 100 + script.plex/white-square-top-rounded.png + + + String.IsEmpty(ListItem.Property(first)) + String.IsEmpty(ListItem.Property(last)) + String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 500 + 100 + script.plex/white-square.png + + + !String.IsEmpty(ListItem.Property(last)) + 0 + 0 + 500 + 100 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 500 + 100 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Label2) + + 20 + 20 + 400 + 35 + font12 + left + center + FF000000 + + + + 20 + 50 + 400 + 35 + font12 + left + center + FFFFFFFF + + + + + String.IsEmpty(ListItem.Label2) + 20 + 0 + 400 + 100 + font12 + left + center + FF000000 + + + + + + String.IsEmpty(ListItem.Property(status)) + String.IsEmpty(ListItem.Property(current)) + !String.IsEmpty(ListItem.Property(local)) + 456 + 38 + 24 + 21 + script.plex/home/device/home.png + + + + String.IsEmpty(ListItem.Property(status)) + !String.IsEmpty(ListItem.Property(current)) + !String.IsEmpty(ListItem.Property(local)) + 415 + 38 + 24 + 21 + script.plex/home/device/home.png + + + + !String.IsEmpty(ListItem.Property(status)) + String.IsEmpty(ListItem.Property(current)) + !String.IsEmpty(ListItem.Property(local)) + 415 + 38 + 24 + 21 + script.plex/home/device/home.png + + + + !String.IsEmpty(ListItem.Property(status)) + !String.IsEmpty(ListItem.Property(current)) + !String.IsEmpty(ListItem.Property(local)) + 374 + 38 + 24 + 21 + script.plex/home/device/home.png + + + !String.IsEmpty(ListItem.Property(status)) + String.IsEmpty(ListItem.Property(current)) + 456 + 38 + 24 + 24 + script.plex/home/device/focus-$INFO[ListItem.Property(status)] + + + !String.IsEmpty(ListItem.Property(status)) + !String.IsEmpty(ListItem.Property(current)) + 415 + 38 + 24 + 24 + script.plex/home/device/focus-$INFO[ListItem.Property(status)] + + + !String.IsEmpty(ListItem.Property(current)) + 449 + 38 + 31 + 24 + script.plex/home/device/check.png + + + + + + + 492 + 20 + 8 + 860 + - + script.plex/white-square.png + script.plex/white-square.png + - + - + false + vertical + false + 250 + + + + + + auto + 66 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + SetFocus(250) + + + -87 + 87 + 66 + + 0 + 14 + 40 + 39 + $INFO[Window.Property(user.avatar)] + + + String.IsEmpty(Window.Property(user.avatar)) + 0 + 14 + 40 + 39 + font10 + center + center + FFFFFFFF + + + + !Control.HasFocus(202) + 53 + 27 + 15 + 13 + script.plex/indicators/dropdown-triangle.png + 99FFFFFF + + + Control.HasFocus(202) + 53 + 27 + 15 + 13 + script.plex/indicators/dropdown-triangle.png + FF222222 + + + Control.HasFocus(250) | !String.IsEmpty(Window.Property(show.options)) + -213 + 70 + + -40 + -40 + 380 + 146 + script.plex/drop-shadow.png + + + 226 + -13 + 15 + 13 + script.plex/indicators/dropdown-triangle.png + FF1F1F1F + + + + 0 + 0 + 300 + 198 + 202 + noop + 201 + SetProperty(show.options,) + 200 + vertical + + + + !String.IsEmpty(ListItem.Property(first)) + 0 + 0 + 300 + 66 + script.plex/white-square-top-rounded.png + + + String.IsEmpty(ListItem.Property(first)) + String.IsEmpty(ListItem.Property(last)) + String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 300 + 66 + script.plex/white-square.png + + + !String.IsEmpty(ListItem.Property(last)) + 0 + 0 + 300 + 66 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 300 + 66 + script.plex/white-square-rounded.png + + + 0 + 0 + 300 + 66 + font12 + center + center + FFFFFFFF + + + + + + !String.IsEmpty(ListItem.Property(first)) + 0 + 0 + 300 + 66 + script.plex/white-square-top-rounded.png + + + String.IsEmpty(ListItem.Property(first)) + String.IsEmpty(ListItem.Property(last)) + String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 300 + 66 + script.plex/white-square.png + + + !String.IsEmpty(ListItem.Property(last)) + 0 + 0 + 300 + 66 + script.plex/white-square-top-rounded.png + + + !String.IsEmpty(ListItem.Property(only)) + 0 + 0 + 300 + 66 + script.plex/white-square-rounded.png + + + 0 + 0 + 300 + 66 + font12 + center + center + FF000000 + + + + + + + + + 40 + 10 + - + + + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + String.IsEmpty(Window.Property(busy)) + !String.IsEmpty(Window.Property(no.content)) + 0 + 465 + + false + 60 + 0 + 1800 + 35 + font13 + center + FFFFFFFF + + + + false + 60 + 60 + 1800 + 35 + font13 + center + FFCCCCCC + + + + + + !String.IsEmpty(Window.Property(busy)) + Visible + 840 + 465 + + 0 + 0 + 240 + 150 + script.plex/busy-back.png + A0FFFFFF + + + 75 + 56 + 90 + 38 + script.plex/busy.gif + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-info.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-info.xml new file mode 100644 index 000000000..9c07ff9b2 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-info.xml @@ -0,0 +1,281 @@ + + + 152 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + 0 + 135 + + + 60 + 0 + + !String.IsEmpty(Window.Property(is.poster)) + + 0 + 0 + 519 + 769 + $INFO[Window.Property(thumb.fallback)] + scale + + + 0 + 0 + 519 + 769 + $INFO[Window.Property(thumb)] + scale + + + + !String.IsEmpty(Window.Property(is.square)) + + 0 + 0 + 519 + 519 + $INFO[Window.Property(thumb.fallback)] + keep + + + 0 + 0 + 519 + 519 + $INFO[Window.Property(thumb)] + scale + + + + !String.IsEmpty(Window.Property(is.16x9)) + + 0 + 0 + 519 + 292 + $INFO[Window.Property(thumb.fallback)] + scale + + + 0 + 0 + 519 + 292 + $INFO[Window.Property(thumb)] + scale + + + + 579 + 5 + 1190 + 40 + font13 + left + center + FFFFFFFF + + + + 579 + 55 + 1190 + 40 + font13 + left + center + FFDDDDDD + + + + 579 + 157 + 152 + 1190 + 718 + font13 + left + FFDDDDDD + + + + + 1794 + 157 + 6 + 718 + true + script.plex/white-square.png + script.plex/white-square.png + script.plex/white-square.png + - + - + false + vertical + false + 204 + + + + + + 201 + 0 + 0 + 1920 + 135 + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 152 + 152 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9-chunked.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9-chunked.xml new file mode 100644 index 000000000..b209ee461 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9-chunked.xml @@ -0,0 +1,879 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + 0 + 135 + 101 + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 750 + 0 + 1170 + 1080 + + 0 + 0 + 1170 + 1080 + script.plex/white-square.png + 20000000 + + + + 0 + 0 + 1170 + 945 + 600 + 151 + 304 + 0 + vertical + 4 + 152 + 5 + + + + 120 + 24 + + + !String.IsEmpty(ListItem.Property(unwatched)) + 880 + -3 + 35 + 35 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 861 + 14 + + 0 + 0 + 54 + 42 + script.plex/white-square-rounded.png + + + 0 + 0 + 54 + 42 + font10 + center + center + FF000000 + + + + + 0 + 0 + + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + + + String.IsEmpty(ListItem.Property(is.footer)) + !String.IsEmpty(ListItem.Label) + 0 + 72 + 915 + 2 + script.plex/white-square.png + 40000000 + + + + + + + + + !Control.HasFocus(101) + 120 + 24 + + + !String.IsEmpty(ListItem.Property(unwatched)) + 880 + -2 + 35 + 35 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 861 + 14 + + 0 + 0 + 54 + 42 + script.plex/white-square-rounded.png + + + 0 + 0 + 54 + 42 + font10 + center + center + FF000000 + + + + + 0 + 0 + + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + + + String.IsEmpty(ListItem.Property(is.footer)) + !String.IsEmpty(ListItem.Label) + 0 + 72 + 915 + 2 + script.plex/white-square.png + 40000000 + + + + + Control.HasFocus(101) + 63 + 21 + + -40 + -40 + 1085 + 156 + script.plex/square-rounded-shadow.png + + + 0 + 0 + 1005 + 76 + script.plex/white-square-rounded.png + FFE5A00D + + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 957 + 0 + 48 + 48 + script.plex/indicators/unwatched-rounded.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 933 + 15 + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 0 + 0 + 57 + 46 + script.plex/white-square-rounded.png + + + 0 + 0 + 57 + 46 + font10 + center + center + FF000000 + + + + + 60 + 0 + + 0 + 23 + 915 + 30 + font12 + left + center + DF000000 + + + + + + + + + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),episode) + + 1128 + 33 + 10 + 879 + 101 + 951 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + String.IsEqual(Window(10000).Property(script.plex.item.type),episode) + 1128 + 33 + 10 + 879 + + 0 + 0 + 10 + 879 + script.plex/white-square-rounded.png + + + 0 + 0 + + !Control.HasFocus(951) + String.IsEmpty(Window.Property(dragging)) + 0 + 0 + 10 + 10 + script.plex/white-square-rounded.png + + + Control.HasFocus(951) | !String.IsEmpty(Window.Property(dragging)) + 0 + 0 + 10 + 10 + script.plex/white-square-rounded.png + + + + + 0 + 0 + 10 + 879 + 152 + - + - + + + + + + + 0 + -135 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + VisibleChange + 301 + 30 + -25 + 1000 + 145 + 200 + 101 + 101 + -20 + horizontal + 200 + true + !String.IsEmpty(Window.Property(initialized)) + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + String.IsEmpty(Window.Property(no.options)) | Player.HasAudio + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/chapters-focus.png + script.plex/buttons/chapters.png + + + + + + + 60 + 248 + + !String.IsEqual(Window.Property(media),show) + !String.IsEqual(Window.Property(media),movie) + 0 + 0 + 630 + 355 + 500 + $INFO[Container(101).ListItem.Property(art)] + scale + + + String.IsEqual(Window.Property(media),show) | String.IsEqual(Window.Property(media),movie) + 0 + 0 + 630 + 355 + 500 + $INFO[Container(101).ListItem.Property(art)] + scale + + + 0 + 355 + 440 + 80 + font12 + left + center + FFFFFFFF + + + + 630 + 355 + 180 + 80 + font12 + right + center + FFFFFFFF + + + + 0 + 435 + 630 + 2 + script.plex/white-square.png + 40000000 + + + 0 + 463 + 630 + 307 + font12 + left + FFDDDDDD + + + + + + String.IsEqual(Window(10000).Property(script.plex.sort),titleSort) + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 151 + 1830 + 150 + 20 + 920 + + 0 + 0 + 34 + 1050 + 100 + 152 + 200 + vertical + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + Control.HasFocus(151) + 0 + 0 + + Control.HasFocus(151) + 0 + 0 + 34 + 34 + FFE5A00D + script.plex/white-outline-rounded.png + + + + + + + + + 201 + 0 + 0 + 1920 + 135 + !String.IsEmpty(Window.Property(initialized)) + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font12 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 211 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 311 + String.IsEmpty(Window.Property(hide.filteroptions)) + 340 + 35 + 1000 + 65 + right + 30 + horizontal + 204 + 210 + 50 + + !String.IsEqual(Window.Property(media.itemType),folder) + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + !String.IsEqual(Window.Property(media.itemType),folder) + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + !String.IsEqual(Window.Property(media),show) + !String.IsEqual(Window.Property(media),movie) + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 20 + 0 + + + + String.IsEqual(Window.Property(media),show) | String.IsEqual(Window.Property(media),movie) + auto + 65 + font12 + FFFFFFFF + FF000000 + FFFFFFFF + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + !String.IsEqual(Window.Property(media.itemType),folder) + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9.xml new file mode 100644 index 000000000..efd185bf1 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-16x9.xml @@ -0,0 +1,842 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + 60 + 248 + + !String.IsEqual(Window.Property(media),show) + !String.IsEqual(Window.Property(media),movie) + 0 + 0 + 630 + 355 + 500 + $INFO[Container(101).ListItem.Property(art)] + scale + + + String.IsEqual(Window.Property(media),show) | String.IsEqual(Window.Property(media),movie) + 0 + 0 + 630 + 355 + 500 + $INFO[Container(101).ListItem.Property(art)] + scale + + + 0 + 355 + 440 + 80 + font12 + left + center + FFFFFFFF + + + + 630 + 355 + 180 + 80 + font12 + right + center + FFFFFFFF + + + + 0 + 435 + 630 + 2 + script.plex/white-square.png + 40000000 + + + 0 + 463 + 630 + 307 + font12 + left + FFDDDDDD + + + + + + 0 + 135 + 101 + + + VisibleChange + 301 + 30 + -25 + 1000 + 145 + 200 + 101 + 101 + -20 + horizontal + 200 + true + !String.IsEmpty(Window.Property(initialized)) + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + String.IsEmpty(Window.Property(no.options)) | Player.HasAudio + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/chapters-focus.png + script.plex/buttons/chapters.png + + + + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 750 + 0 + 1170 + 1080 + + 0 + 0 + 1170 + 1080 + script.plex/white-square.png + 20000000 + + + + 0 + 0 + 1170 + 945 + 600 + 151 + 304 + 200 + vertical + 4 + 152 + + + + 120 + 24 + + + !String.IsEmpty(ListItem.Property(unwatched)) + 880 + -3 + 35 + 35 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 861 + 14 + + 0 + 0 + 54 + 42 + script.plex/white-square-rounded.png + + + 0 + 0 + 54 + 42 + font10 + center + center + FF000000 + + + + + 0 + 0 + + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 72 + 915 + 2 + script.plex/white-square.png + 40000000 + + + + + + + + + !Control.HasFocus(101) + 120 + 24 + + + !String.IsEmpty(ListItem.Property(unwatched)) + 880 + -2 + 35 + 35 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 861 + 14 + + 0 + 0 + 54 + 42 + script.plex/white-square-rounded.png + + + 0 + 0 + 54 + 42 + font10 + center + center + FF000000 + + + + + 0 + 0 + + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 72 + 915 + 2 + script.plex/white-square.png + 40000000 + + + + + Control.HasFocus(101) + 63 + 21 + + -40 + -40 + 1085 + 156 + script.plex/square-rounded-shadow.png + + + 0 + 0 + 1005 + 76 + script.plex/white-square-rounded.png + FFE5A00D + + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 957 + 0 + 48 + 48 + script.plex/indicators/unwatched-rounded.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 933 + 15 + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 0 + 0 + 57 + 46 + script.plex/white-square-rounded.png + + + 0 + 0 + 57 + 46 + font10 + center + center + FF000000 + + + + + 60 + 0 + + 0 + 0 + 510 + 72 + font12 + left + center + DF000000 + + + + + + + + + + + + 1128 + 33 + 10 + 879 + 101 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + + + + String.IsEqual(Window(10000).Property(script.plex.sort),titleSort) + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 151 + 1830 + 150 + 20 + 920 + + 0 + 0 + 34 + 1050 + 100 + 152 + 200 + vertical + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + Control.HasFocus(151) + 0 + 0 + + Control.HasFocus(151) + 0 + 0 + 34 + 34 + FFE5A00D + script.plex/white-outline-rounded.png + + + + + + + + + 201 + 0 + 0 + 1920 + 135 + !String.IsEmpty(Window.Property(initialized)) + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font12 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 211 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 311 + String.IsEmpty(Window.Property(hide.filteroptions)) + 340 + 35 + 1000 + 65 + right + 30 + horizontal + 204 + 210 + 50 + + !String.IsEqual(Window.Property(media.itemType),folder) + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + !String.IsEqual(Window.Property(media.itemType),folder) + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + !String.IsEqual(Window.Property(media),show) + !String.IsEqual(Window.Property(media),movie) + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 20 + 0 + + + + String.IsEqual(Window.Property(media),show) | String.IsEqual(Window.Property(media),movie) + auto + 65 + font12 + FFFFFFFF + FF000000 + FFFFFFFF + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + !String.IsEqual(Window.Property(media.itemType),folder) + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square-chunked.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square-chunked.xml new file mode 100644 index 000000000..b3aab3078 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square-chunked.xml @@ -0,0 +1,943 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + 0 + 135 + 101 + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 750 + 0 + 1170 + 1080 + + 0 + 0 + 1170 + 1080 + script.plex/white-square.png + 20000000 + + + + 0 + 0 + 1170 + 945 + 600 + 151 + 304 + 0 + vertical + 4 + 152 + 5 + + + + 120 + 24 + + + !String.IsEmpty(ListItem.Property(unwatched)) + 880 + -3 + 35 + 35 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 861 + 14 + + 0 + 0 + 54 + 42 + script.plex/white-square-rounded.png + + + 0 + 0 + 54 + 42 + font10 + center + center + FF000000 + + + + + 0 + 0 + + String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + + + String.IsEmpty(ListItem.Property(is.footer)) + !String.IsEmpty(ListItem.Label) + 0 + 72 + 915 + 2 + script.plex/white-square.png + 40000000 + + + + + + + + + !Control.HasFocus(101) + 120 + 24 + + + !String.IsEmpty(ListItem.Property(unwatched)) + 880 + -2 + 35 + 35 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 861 + 14 + + 0 + 0 + 54 + 42 + script.plex/white-square-rounded.png + + + 0 + 0 + 54 + 42 + font10 + center + center + FF000000 + + + + + 0 + 0 + + 0 + 0 + + String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + + + + String.IsEmpty(ListItem.Property(is.footer)) + !String.IsEmpty(ListItem.Label) + 0 + 72 + 915 + 2 + script.plex/white-square.png + 40000000 + + + + + Control.HasFocus(101) + 63 + 21 + + -40 + -40 + 1085 + 156 + script.plex/square-rounded-shadow.png + + + 0 + 0 + 1005 + 76 + script.plex/white-square-rounded.png + FFE5A00D + + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 957 + 0 + 48 + 48 + script.plex/indicators/unwatched-rounded.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 933 + 15 + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 0 + 0 + 57 + 46 + script.plex/white-square-rounded.png + + + 0 + 0 + 57 + 46 + font10 + center + center + FF000000 + + + + + 60 + 0 + + String.IsEmpty(ListItem.Property(is.folder)) + 0 + 23 + 885 + 30 + font12 + left + center + DF000000 + + + + !String.IsEmpty(ListItem.Property(is.folder)) + 0 + 23 + 885 + 30 + font10 + left + center + FF000000 + + + + + + + + + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),album) + + 1128 + 33 + 10 + 879 + 101 + 951 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + String.IsEqual(Window(10000).Property(script.plex.item.type),album) + 1128 + 33 + 10 + 879 + + 0 + 0 + 10 + 879 + script.plex/white-square-rounded.png + + + 0 + 0 + + !Control.HasFocus(951) + String.IsEmpty(Window.Property(dragging)) + 0 + 0 + 10 + 10 + script.plex/white-square-rounded.png + + + Control.HasFocus(951) | !String.IsEmpty(Window.Property(dragging)) + 0 + 0 + 10 + 10 + script.plex/white-square-rounded.png + + + + + 0 + 0 + 10 + 879 + 152 + - + - + + + + + + + 0 + -135 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + 301 + 30 + -25 + 1000 + 145 + 200 + 101 + 101 + -20 + horizontal + 200 + true + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + String.IsEmpty(Window.Property(no.options)) | Player.HasAudio + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + String.IsEmpty(Window.Property(hide.filteroptions)) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/chapters-focus.png + script.plex/buttons/chapters.png + + + + + + + 60 + 248 + + String.IsEqual(Window.Property(media),photo) | String.IsEqual(Window.Property(media),photodirectory) + + 0 + 0 + 630 + 355 + script.plex/white-square.png + + + 0 + 0 + 630 + 355 + 500 + $INFO[Container(101).ListItem.Thumb] + keep + + + + String.IsEqual(Window.Property(media),artist) + 0 + 0 + 355 + 355 + 500 + $INFO[Container(101).ListItem.Thumb] + scale + + + !String.IsEmpty(Container(101).ListItem.Label2) + + 0 + 355 + 510 + 80 + font12 + left + center + FFFFFFFF + + + + 630 + 355 + 110 + 80 + font12 + right + center + FFFFFFFF + + + + + String.IsEmpty(Container(101).ListItem.Label2) + + 0 + 355 + 630 + 80 + font12 + left + center + FFFFFFFF + + + + + 0 + 435 + 630 + 2 + script.plex/white-square.png + 40000000 + + + 0 + 463 + 630 + 307 + font12 + left + FFDDDDDD + + + + + + String.IsEqual(Window(10000).Property(script.plex.sort),titleSort) + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 151 + 1830 + 150 + 20 + 920 + + 0 + 0 + 34 + 1050 + 100 + 152 + 200 + vertical + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + Control.HasFocus(151) + 0 + 0 + + Control.HasFocus(151) + 0 + 0 + 34 + 34 + FFE5A00D + script.plex/white-outline-rounded.png + + + + + + + + + 201 + 0 + 0 + 1920 + 135 + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font12 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 211 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 311 + String.IsEmpty(Window.Property(hide.filteroptions)) + 340 + 35 + 1000 + 65 + right + 30 + horizontal + 204 + 210 + 50 + + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + !String.IsEqual(Window.Property(media),artist) + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 20 + 0 + + + + String.IsEqual(Window.Property(media),artist) + auto + 65 + font12 + FFFFFFFF + FF000000 + FFFFFFFF + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square.xml new file mode 100644 index 000000000..0b80694ee --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-listview-square.xml @@ -0,0 +1,908 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + 60 + 248 + + String.IsEqual(Window.Property(media),photo) | String.IsEqual(Window.Property(media),photodirectory) + + 0 + 0 + 630 + 355 + script.plex/white-square.png + + + 0 + 0 + 630 + 355 + 500 + $INFO[Container(101).ListItem.Thumb] + keep + + + + String.IsEqual(Window.Property(media),artist) + 0 + 0 + 355 + 355 + 500 + $INFO[Container(101).ListItem.Thumb] + scale + + + !String.IsEmpty(Container(101).ListItem.Label2) + + 0 + 355 + 310 + 80 + font12 + left + center + FFFFFFFF + + + + 630 + 355 + 310 + 80 + font12 + right + center + FFFFFFFF + + + + + String.IsEmpty(Container(101).ListItem.Label2) + + 0 + 355 + 630 + 80 + font12 + left + center + FFFFFFFF + + + + + 0 + 435 + 630 + 2 + script.plex/white-square.png + 40000000 + + + 0 + 463 + 630 + 307 + font12 + left + FFDDDDDD + + + + + + 0 + 135 + 101 + + + 301 + 30 + -25 + 1000 + 145 + 200 + 101 + 101 + -20 + horizontal + 200 + true + !String.IsEmpty(Window.Property(initialized)) + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + String.IsEmpty(Window.Property(no.options)) | Player.HasAudio + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + String.IsEmpty(Window.Property(hide.filteroptions)) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/chapters-focus.png + script.plex/buttons/chapters.png + + + + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 750 + 0 + 1170 + 1080 + + 0 + 0 + 1170 + 1080 + script.plex/white-square.png + 20000000 + + + + 0 + 0 + 1170 + 945 + 600 + 151 + 304 + 200 + vertical + 4 + 152 + + + + 120 + 24 + + + !String.IsEmpty(ListItem.Property(unwatched)) + 880 + -3 + 35 + 35 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 861 + 14 + + 0 + 0 + 54 + 42 + script.plex/white-square-rounded.png + + + 0 + 0 + 54 + 42 + font10 + center + center + FF000000 + + + + + 0 + 0 + + String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 72 + 915 + 2 + script.plex/white-square.png + 40000000 + + + + + + + + + !Control.HasFocus(101) + 120 + 24 + + + !String.IsEmpty(ListItem.Property(unwatched)) + 880 + -2 + 35 + 35 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 861 + 14 + + 0 + 0 + 54 + 42 + script.plex/white-square-rounded.png + + + 0 + 0 + 54 + 42 + font10 + center + center + FF000000 + + + + + 0 + 0 + + 0 + 0 + + String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 915 + 72 + font10 + left + center + FFFFFFFF + + + + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 72 + 915 + 2 + script.plex/white-square.png + 40000000 + + + + + Control.HasFocus(101) + 63 + 21 + + -40 + -40 + 1085 + 156 + script.plex/square-rounded-shadow.png + + + 0 + 0 + 1005 + 76 + script.plex/white-square-rounded.png + FFE5A00D + + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 957 + 0 + 48 + 48 + script.plex/indicators/unwatched-rounded.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 933 + 15 + + !String.IsEmpty(ListItem.Property(unwatched.count)) + 0 + 0 + 57 + 46 + script.plex/white-square-rounded.png + + + 0 + 0 + 57 + 46 + font10 + center + center + FF000000 + + + + + 60 + 0 + + String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 885 + 72 + font12 + left + center + DF000000 + + + + !String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 885 + 72 + font10 + left + center + FF000000 + + + + + + + + + + + + 1128 + 33 + 10 + 879 + 101 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + + + + String.IsEqual(Window(10000).Property(script.plex.sort),titleSort) + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 151 + 1830 + 150 + 20 + 920 + + 0 + 0 + 34 + 1050 + 100 + 152 + 200 + vertical + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + Control.HasFocus(151) + 0 + 0 + + Control.HasFocus(151) + 0 + 0 + 34 + 34 + FFE5A00D + script.plex/white-outline-rounded.png + + + + + + + + + 201 + 0 + 0 + 1920 + 135 + !String.IsEmpty(Window.Property(initialized)) + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font12 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 211 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 311 + String.IsEmpty(Window.Property(hide.filteroptions)) + 340 + 35 + 1000 + 65 + right + 30 + horizontal + 204 + 210 + 50 + + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + !String.IsEqual(Window.Property(media),artist) + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 20 + 0 + + + + String.IsEqual(Window.Property(media),artist) + auto + 65 + font12 + FFFFFFFF + FF000000 + FFFFFFFF + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-music_current_playlist.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-music_current_playlist.xml new file mode 100644 index 000000000..28881da16 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-music_current_playlist.xml @@ -0,0 +1,790 @@ + + + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + + String.IsEmpty(Window.Property(use_solid_background)) + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + false + + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Player.Art(fanart)] + + + !String.IsEmpty(Player.Art(fanart)) + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + D0404040 + + + + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Player.Art(landscape)] + + + + + 0 + 0 + 101 + + + 90 + 0 + + -15 + 75 + 669 + 669 + script.plex/white-square.png + 20FFFFFF + + + 0 + 90 + 639 + 639 + $INFO[Player.Art(thumb)] + scale + + + 0 + 764 + 639 + 35 + font12 + left + center + FFFFFFFF + + + + 0 + 799 + 639 + 35 + font12 + left + center + FFFFFFFF + + + + 0 + 834 + 639 + 35 + font12 + left + center + FFFFFFFF + + + + 0 + 869 + 639 + 35 + font12 + left + center + FFFFFFFF + + + + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 819 + 0 + 1101 + 1080 + + 0 + 0 + 1101 + 1080 + script.plex/white-square.png + 20000000 + + + 0 + 0 + 1101 + 1080 + 200 + 152 + 411 + 200 + vertical + 4 + 152 + + + + 120 + 24 + + !String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + -10 + 0 + 60 + 100 + font10 + center + center + D8FFFFFF + + + + String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 2 + 32.5 + 35 + 35 + script.plex/indicators/playing-circle.png + FFE5A00D + + + 63 + 11 + 74 + 74 + $INFO[ListItem.Thumb] + scale + + + 168 + 0 + + 0 + 15 + 723 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 723 + 30 + font10 + left + center + B8FFFFFF + + + + + 661 + 0 + 200 + 100 + font10 + right + center + D8FFFFFF + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 97 + 861 + 2 + script.plex/white-square.png + 40000000 + + + + + + + + + !Control.HasFocus(101) + 120 + 24 + + !String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + -10 + 0 + 60 + 100 + font10 + center + center + D8FFFFFF + + + + String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 0 + 32.5 + 35 + 35 + script.plex/indicators/playing-circle.png + FFE5A00D + + + 63 + 11 + 74 + 74 + $INFO[ListItem.Thumb] + scale + + + 168 + 0 + + 0 + 15 + 723 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 723 + 30 + font10 + left + center + B8FFFFFF + + + + + 669 + 0 + 200 + 100 + font10 + right + center + D8FFFFFF + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 97 + 861 + 2 + script.plex/white-square.png + 40000000 + + + + + Control.HasFocus(101) + 63 + 21 + + -40 + -40 + 1055 + 180 + script.plex/square-rounded-shadow.png + + + 0 + 0 + 975 + 100 + script.plex/white-square-rounded.png + FFE5A00D + + + !String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 24 + 0 + 60 + 100 + font12 + center + center + B8000000 + + + + String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 36 + 32.5 + 35 + 35 + script.plex/indicators/playing-circle.png + FF000000 + + + 103 + 0 + 100 + 100 + $INFO[ListItem.Thumb] + scale + + + 235 + 0 + + 0 + 16 + 669 + 30 + font12 + left + center + DF000000 + + + + 0 + 51 + 669 + 30 + font10 + left + center + 98000000 + + + + + 735 + 0 + 200 + 100 + font12 + right + center + B8000000 + + + + + + + + + + + 1059 + 33 + 10 + 1014 + 101 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + + + + 406 + + 0 + 964 + 819 + + 124 + center + 500 + 100 + -40 + horizontal + 200 + false + + Conditional + Conditional + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 402 + 411 + font12 + - + - + + + + !Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat.png + + + Playlist.IsRepeatOne + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-one.png + + + + Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-focus.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-focus.png + + + Playlist.IsRepeatOne + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-one-focus.png + + + + + + String.IsEmpty(Window.Property(pq.isremote)) + Focus + UnFocus + + 125 + 101 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + Playlist.IsRandom + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + PlayerControl(RandomOn) + PlayerControl(RandomOff) + + + + + !String.IsEmpty(Window.Property(pq.isremote)) + Conditional + Conditional + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 404 + 401 + font12 + - + - + + + + String.IsEmpty(Window.Property(pq.shuffled)) + + !Control.HasFocus(422) + 0 + 0 + 125 + 101 + script.plex/buttons/shuffle.png + + + Control.HasFocus(422) + 0 + 0 + 125 + 101 + script.plex/buttons/shuffle-focus.png + + + + !String.IsEmpty(Window.Property(pq.shuffled)) + + !Control.HasFocus(422) + 0 + 0 + 125 + 101 + script.plex/buttons/shuffle.png + + + Control.HasFocus(422) + 0 + 0 + 125 + 101 + script.plex/buttons/shuffle-focus.png + + + + + + MusicPlayer.HasPrevious | !String.IsEmpty(Window.Property(pq.hasprevious)) + Focus + UnFocus + + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + Focus + UnFocus + + 125 + 101 + font12 + script.plex/buttons/pause-focus.png + script.plex/buttons/pause.png + Player.Paused | Player.Forwarding | Player.Rewinding + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + PlayerControl(Play) + + + + Focus + UnFocus + + 125 + 101 + font12 + script.plex/buttons/stop-focus.png + script.plex/buttons/stop.png + PlayerControl(Stop) + + + + MusicPlayer.HasNext | !String.IsEmpty(Window.Property(pq.hasnext)) + Focus + UnFocus + + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + + Focus + UnFocus + + 125 + 101 + font12 + script.plex/buttons/pqueue-focus.png + script.plex/buttons/pqueue.png + + Close + + + Focus + UnFocus + + 125 + 101 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + + 0 + 940 + + Player.HasAudio + + 0 + 0 + 819 + 10 + 100 + 400 + script.plex/white-square.png + script.plex/white-square.png + A0000000 + + + Control.HasFocus(500) + Visible + 0 + 1 + 1 + 8 + script.plex/white-square.png + FFE5A00D + + + !Control.HasFocus(500) + Progressbar + 0 + 2 + 819 + 6 + script.plex/transparent-6px.png + - + script.plex/white-square-6px.png + - + - + Player.Progress + + + Control.HasFocus(500) + Progressbar + 0 + 2 + 819 + 6 + script.plex/transparent-6px.png + - + script.plex/white-square-6px.png + - + - + Player.Progress + + + + + + + + + Control.HasFocus(500) + 0 + 896 + + -50 + 0 + + Visible + 0 + 0 + 101 + 39 + script.plex/indicators/player-selection-time_box.png + D0000000 + + + 0 + 0 + 101 + 40 + font10 + center + center + FFFFFFFF + + + + + Visible + -6 + 39 + 15 + 7 + script.plex/indicators/player-selection-time_arrow.png + D0000000 + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-music_player.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-music_player.xml new file mode 100644 index 000000000..18926cfc2 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-music_player.xml @@ -0,0 +1,578 @@ + + + + 1 + 0 + 0 + + 406 + + + + String.IsEmpty(Window.Property(use_solid_background)) + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + false + + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Player.Art(fanart)] + + + !String.IsEmpty(Player.Art(fanart)) + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + D0404040 + + + + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Player.Art(landscape)] + + + + + 75 + 75 + 786 + 786 + script.plex/white-square.png + 20FFFFFF + + + 90 + 90 + 756 + 756 + $INFO[Player.Art(thumb)] + + + + 939 + 0 + + 0 + 305 + 1000 + 54 + font13 + left + center + FFFFFFFF + + + + 0 + 359 + 1000 + 54 + font13 + left + center + FFFFFFFF + + + + 0 + 470 + 1000 + 54 + font13 + left + center + FFFFFFFF + + + + 0 + 580 + 1000 + 54 + font13 + left + center + FFFFFFFF + + + + + + 1845 + 0 + + 0 + 738 + 1000 + 54 + font13 + right + center + 80FFFFFF + + + + 0 + 794 + 1000 + 54 + font13 + right + center + 80FFFFFF + + + + + + + + 406 + + 360 + 964 + 1200 + + 124 + center + 100 + -40 + horizontal + 200 + true + + Conditional + Conditional + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 402 + 411 + font12 + - + - + + + + !Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat.png + + + Playlist.IsRepeatOne + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-one.png + + + + Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-focus.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-focus.png + + + Playlist.IsRepeatOne + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-one-focus.png + + + + + + String.IsEmpty(Window.Property(pq.isremote)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + Playlist.IsRandom + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + PlayerControl(RandomOn) + PlayerControl(RandomOff) + + + + + !String.IsEmpty(Window.Property(pq.isremote)) + Conditional + Conditional + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 404 + 401 + font12 + - + - + + + + String.IsEmpty(Window.Property(pq.shuffled)) + + !Control.HasFocus(422) + 0 + 0 + 125 + 101 + script.plex/buttons/shuffle.png + + + Control.HasFocus(422) + 0 + 0 + 125 + 101 + script.plex/buttons/shuffle-focus.png + + + + !String.IsEmpty(Window.Property(pq.shuffled)) + + !Control.HasFocus(422) + 0 + 0 + 125 + 101 + script.plex/buttons/shuffle.png + + + Control.HasFocus(422) + 0 + 0 + 125 + 101 + script.plex/buttons/shuffle-focus.png + + + + + + MusicPlayer.HasPrevious | !String.IsEmpty(Window.Property(pq.hasprevious)) + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/pause-focus.png + script.plex/buttons/pause.png + Player.Paused | Player.Forwarding | Player.Rewinding + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + PlayerControl(Play) + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/stop-focus.png + script.plex/buttons/stop.png + PlayerControl(Stop) + + + + MusicPlayer.HasNext | !String.IsEmpty(Window.Property(pq.hasnext)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + + + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/pqueue-focus.png + script.plex/buttons/pqueue.png + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + + 0 + 940 + + Player.HasAudio + + 0 + 0 + 1920 + 10 + 400 + script.plex/white-square.png + script.plex/white-square.png + A0000000 + + + Control.HasFocus(100) + Visible + 0 + 1 + 1 + 8 + script.plex/white-square.png + FFE5A00D + + + !Control.HasFocus(100) + Progressbar + 0 + 2 + 1920 + 6 + script.plex/transparent-6px.png + - + script.plex/white-square-6px.png + - + - + Player.Progress + + + Control.HasFocus(100) + Progressbar + 0 + 2 + 1920 + 6 + script.plex/transparent-6px.png + - + script.plex/white-square-6px.png + - + - + Player.Progress + + + + + + + + + Control.HasFocus(100) + 0 + 896 + + -50 + 0 + + Visible + 0 + 0 + 101 + 39 + script.plex/indicators/player-selection-time_box.png + D0000000 + + + 0 + 0 + 101 + 40 + font10 + center + center + FFFFFFFF + + + + + Visible + -6 + 39 + 15 + 7 + script.plex/indicators/player-selection-time_arrow.png + D0000000 + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-options_dialog.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-options_dialog.xml new file mode 100644 index 000000000..ce442546c --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-options_dialog.xml @@ -0,0 +1,136 @@ + + 1001 + + 1 + 0 + 0 + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + !String.IsEmpty(Window.Property(initialized)) + 585 + 360 + + -40 + -40 + 830 + 440 + script.plex/drop-shadow.png + + + 0 + 0 + 750 + 360 + script.plex/white-square-rounded.png + + + 0 + 0 + 750 + 80 + script.plex/white-square-top-rounded.png + + + + 48 + 31 + 19 + 19 + script.plex/indicators/circle-19.png + + + + 115 + 0 + 575 + 80 + font12 + left + center + FFE5A00D + + + + + 115 + 105 + 575 + 125 + font10 + left + FFFFFFFF + + + + + 1001 + -10 + 220 + 770 + 155 + right + -50 + horizontal + 0 + true + + !String.IsEmpty(Window.Property(button.0)) + Focus + UnFocus + 0 + 0 + auto + 143 + font10 + script.plex/buttons/blank-focus.png + script.plex/buttons/blank.png + 70 + FF000000 + FF000000 + + + + !String.IsEmpty(Window.Property(button.1)) + Focus + UnFocus + 0 + 0 + auto + 143 + font10 + script.plex/buttons/blank-focus.png + script.plex/buttons/blank.png + 70 + FF000000 + FF000000 + + + + !String.IsEmpty(Window.Property(button.2)) + Focus + UnFocus + 0 + 0 + auto + 143 + font10 + script.plex/buttons/blank-focus.png + script.plex/buttons/blank.png + 70 + FF000000 + FF000000 + + + + + + + \ No newline at end of file diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-photo.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-photo.xml new file mode 100644 index 000000000..071430b70 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-photo.xml @@ -0,0 +1,717 @@ + + + + 1 + 0 + 0 + + 250 + 100 + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + Conditional + Conditional + Conditional + Conditional + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(photo)] + keep + + + !String.IsEmpty(Window.Property(is.updating)) + VisibleChange + + 840 + 465 + 240 + 150 + script.plex/busy-back.png + A0FFFFFF + + + 915 + 521 + 90 + 38 + script.plex/busy.gif + + + + + 0 + 0 + 1920 + 1080 + - + - + !String.IsEmpty(Window.Property(OSD)) + - + - + + SetProperty(OSD,1) + SetFocus(400) + SetProperty(OSD,) + SetProperty(OSD,1) + SetFocus(400) + SetFocus(501) + SetFocus(400) + + + Conditional + 0 + 0 + + !String.IsEmpty(Window.Property(OSD)) + + 0 + 940 + 1920 + 140 + script.plex/white-square.png + A0000000 + + + 406 + + 360 + 964 + 1200 + + 124 + center + 250 + SetProperty(OSD,) + 250 + SetProperty(OSD,) + -40 + horizontal + 200 + true + + + String.IsEmpty(Window.Property(no.playlist)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/repeat-focus.png + script.plex/buttons/repeat.png + !String.IsEmpty(Window.Property(pq.repeat)) + script.plex/buttons/repeat-focus.png + script.plex/buttons/repeat.png + + + + false + !String.IsEmpty(Window.Property(no.playlist)) + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + String.IsEmpty(Window.Property(no.playlist)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + !String.IsEmpty(Window.Property(pq.shuffled)) + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + false + !String.IsEmpty(Window.Property(no.playlist)) + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/rotate-focus.png + script.plex/buttons/rotate.png + + + + + + String.IsEmpty(Window.Property(hide.prev)) + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + false + !String.IsEmpty(Window.Property(hide.prev)) + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + !String.IsEmpty(Window.Property(playing)) + script.plex/buttons/pause-focus.png + script.plex/buttons/pause.png + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/stop-focus.png + script.plex/buttons/stop.png + + + + String.IsEmpty(Window.Property(hide.next)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + false + !String.IsEmpty(Window.Property(hide.next)) + 0 + 0 + 125 + 101 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + + + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/square2x2-focus.png + script.plex/buttons/square2x2.png + !String.IsEmpty(Window.Property(show.pqueue)) + script.plex/buttons/square2x2-focus.png + script.plex/buttons/square2x2-focus.png + SetProperty(show.pqueue,1) + SetProperty(show.pqueue,) + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/info-focus.png + script.plex/buttons/info.png + !String.IsEmpty(Window.Property(show.info)) + script.plex/buttons/info-focus.png + script.plex/buttons/info-focus.png + SetProperty(show.info,1) + SetProperty(show.info,) + + + + false + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/tags-focus.png + script.plex/buttons/tags.png + + + + false + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + + + 0 + 1080 + 1920 + 135 + SetProperty(OSD,1) + 400 + font12 + - + - + + SetProperty(OSD,1) + SetFocus(400) + + + + 0 + 1080 + 1920 + 135 + script.plex/white-square.png + FF000000 + + + 0 + 1080 + 1920 + 135 + + + 0 + horizontal + 4 + 7 + + + + 0 + 0 + + 2.5 + 6 + + 0 + 0 + 123 + 123 + script.plex/thumb_fallbacks/photo.png + + + 0 + 0 + 123 + 123 + $INFO[ListItem.Thumb] + scale + + + + + + + + + 0 + 0 + + 2.5 + 6 + + 0 + 0 + 123 + 123 + script.plex/thumb_fallbacks/photo.png + + + 0 + 0 + 123 + 123 + $INFO[ListItem.Thumb] + scale + + + + + + + 892.5 + 1080 + 135 + 135 + script.plex/home/selected.png + + + + + + !String.IsEmpty(Window.Property(show.info)) + 1470 + 0 + 450 + 1080 + + 0 + 0 + 450 + 1080 + script.plex/white-square.png + 4C000000 + + + 0 + 0 + 450 + 1080 + 0 + vertical + + + + 450 + 21 + - + - + 00000000 + + + + 450 + 37 + script.plex/white-square.png + script.plex/white-square.png + 00000000 + left + center + 28 + font12 + + + + 450 + 37 + script.plex/white-square.png + script.plex/white-square.png + 00000000 + left + center + 28 + font12 + + + + + 450 + 21 + - + - + 00000000 + + + + + + 450 + 21 + script.plex/white-square.png + script.plex/white-square.png + 99000000 + + + + !String.IsEmpty(Window.Property(camera.model)) + 450 + 37 + + 0 + 0 + 450 + 37 + script.plex/white-square.png + script.plex/white-square.png + 99000000 + left + center + 28 + font12 + + + + 393 + 6 + 29 + 24 + script.plex/indicators/camera.png + A0FFFFFF + + + + !String.IsEmpty(Window.Property(camera.lens)) + 450 + 37 + script.plex/white-square.png + script.plex/white-square.png + 99000000 + left + center + 28 + font12 + + + + !String.IsEmpty(Window.Property(photo.container)) + 0 + 0 + 450 + 37 + + 0 + 0 + 450 + 37 + script.plex/white-square.png + script.plex/white-square.png + 99000000 + left + center + 28 + font12 + + + + 28 + 5 + 200 + 26 + 0 + horizontal + right + + auto + 26 + font10 + center + top + FF000000 + FF000000 + 5 + -3 + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + + + + + + + 450 + 21 + script.plex/white-square.png + script.plex/white-square.png + 99000000 + + + + !String.IsEmpty(Window.Property(camera.settings)) + 450 + 80 + + + 0 + 0 + 450 + 1 + script.plex/indicators/info-sep.png + 99000000 + + + 28 + 0 + 394 + 1 + script.plex/white-square.png + 999B9B9B + + + + 0 + 1 + 450 + 21 + script.plex/white-square.png + script.plex/white-square.png + 99000000 + + + + 0 + 22 + 450 + 37 + script.plex/white-square.png + script.plex/white-square.png + 99000000 + left + center + 28 + font12 + + + + + 0 + 59 + 450 + 21 + script.plex/white-square.png + script.plex/white-square.png + 99000000 + + + + + + !String.IsEmpty(Window.Property(photo.summary)) + 450 + 152 + + 0 + 0 + 450 + 152 + script.plex/white-square.png + 661F1F1F + + + 28 + 24 + 394 + 100 + font12 + left + center + + + + + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-pin_login.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-pin_login.xml new file mode 100644 index 000000000..6dcac7f4a --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-pin_login.xml @@ -0,0 +1,82 @@ + + + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + + !String.IsEmpty(Window.Property(pin.image.0)) + 0 + 0 + 1920 + 1080 + script.plex/sign_in/pin-display.jpg + + + String.IsEmpty(Window.Property(pin.image.0)) + String.IsEmpty(Window.Property(linking)) + 0 + 0 + 1920 + 1080 + script.plex/sign_in/generating-code.jpg + + + !String.IsEmpty(Window.Property(linking)) + 0 + 0 + 1920 + 1080 + script.plex/sign_in/linking-account.jpg + + + + 270 + 738 + 800 + 153 + WeatherTemp + left + center + FFFFFFFF + + + + + 406 + 265 + 708 + 1000 + + 200 + left + 0 + horizontal + + 200 + 200 + $INFO[Window.Property(pin.image.0)] + + + 200 + 200 + $INFO[Window.Property(pin.image.1)] + + + 200 + 200 + $INFO[Window.Property(pin.image.2)] + + + 200 + 200 + $INFO[Window.Property(pin.image.3)] + + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-playlist.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-playlist.xml new file mode 100644 index 000000000..3e00fca9c --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-playlist.xml @@ -0,0 +1,883 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + 0 + 135 + 101 + + + 60 + 0 + + 0 + 5 + 420 + 40 + font13 + left + center + FFFFFFFF + + + + 0 + 60 + 420 + 40 + font13 + left + center + FFFFFFFF + + + + 0 + 142 + 630 + 630 + $INFO[Window.Property(playlist.thumb)] + scale + + + + + VisibleChange + 303 + 50 + 784 + 650 + 145 + 200 + 101 + -50 + horizontal + center + 200 + true + + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + !String.IsEmpty(Window.Property(show.options)) | Player.HasAudio + Focus + UnFocus + + 0 + 0 + 174 + 139 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 750 + 0 + 1170 + 1080 + + 0 + 0 + 1170 + 1080 + script.plex/white-square.png + 20000000 + + + 0 + 0 + 1170 + 945 + 200 + 152 + 300 + 200 + vertical + 4 + 152 + + + + 120 + 24 + + String.IsEmpty(ListItem.Property(track.ID)) | !String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + -10 + 0 + 60 + 100 + font10 + center + center + D8FFFFFF + + + + !String.IsEmpty(ListItem.Property(track.ID)) + String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 2 + 32.5 + 35 + 35 + script.plex/indicators/playing-circle.png + FFE5A00D + + + String.IsEmpty(ListItem.Property(video)) + + 63 + 11 + 74 + 74 + $INFO[ListItem.Thumb] + scale + + + 168 + 0 + + 0 + 15 + 692 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 692 + 30 + font10 + left + center + B8FFFFFF + + + + + + !String.IsEmpty(ListItem.Property(video)) + + 63 + 11 + 132 + 74 + $INFO[ListItem.Thumb] + scale + + + String.IsEmpty(ListItem.Property(watched)) + 895 + -1 + 35 + 35 + script.plex/indicators/unwatched.png + + + 226 + 0 + + 0 + 15 + 584 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 584 + 30 + font10 + left + center + B8FFFFFF + + + + + + 730 + 0 + 200 + 100 + font10 + right + center + D8FFFFFF + + + + + False + 63 + 85 + + 0 + 0 + 870 + 8 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 870 + 6 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 97 + 930 + 2 + script.plex/white-square.png + 40000000 + + + + + + + + + !Control.HasFocus(101) + 120 + 24 + + String.IsEmpty(ListItem.Property(track.ID)) | !String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + -10 + 0 + 60 + 100 + font10 + center + center + D8FFFFFF + + + + !String.IsEmpty(ListItem.Property(track.ID)) + String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 0 + 32.5 + 35 + 35 + script.plex/indicators/playing-circle.png + FFE5A00D + + + String.IsEmpty(ListItem.Property(video)) + + 63 + 11 + 74 + 74 + $INFO[ListItem.Thumb] + scale + + + 168 + 0 + + 0 + 15 + 692 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 692 + 30 + font10 + left + center + B8FFFFFF + + + + + + !String.IsEmpty(ListItem.Property(video)) + + 63 + 11 + 132 + 74 + $INFO[ListItem.Thumb] + scale + + + String.IsEmpty(ListItem.Property(watched)) + 895 + -1 + 35 + 35 + script.plex/indicators/unwatched.png + + + 226 + 0 + + 0 + 15 + 584 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 584 + 30 + font10 + left + center + B8FFFFFF + + + + + + 730 + 0 + 200 + 100 + font10 + right + center + D8FFFFFF + + + + !String.IsEmpty(ListItem.Property(progress)) + 63 + 88 + + 0 + 0 + 870 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 870 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 97 + 930 + 2 + script.plex/white-square.png + 40000000 + + + + + Control.HasFocus(101) + 63 + 21 + + -40 + -40 + 1124 + 180 + script.plex/square-rounded-shadow.png + + + 0 + 0 + 1044 + 100 + script.plex/white-square-rounded.png + FFE5A00D + + + + + String.IsEmpty(ListItem.Property(track.ID)) | !String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 24 + 0 + 60 + 100 + font12 + center + center + B8000000 + + + + !String.IsEmpty(ListItem.Property(track.ID)) + String.IsEqual(ListItem.Property(track.ID),Window(10000).Property(script.plex.track.ID)) + 36 + 32.5 + 35 + 35 + script.plex/indicators/playing-circle.png + FF000000 + + + String.IsEmpty(ListItem.Property(video)) + + String.IsEmpty(ListItem.Property(video)) + 103 + 0 + 100 + 100 + $INFO[ListItem.Thumb] + scale + + + 235 + 0 + + 0 + 16 + 638 + 30 + font12 + left + center + DF000000 + + + + 0 + 51 + 638 + 30 + font10 + left + center + 98000000 + + + + + + !String.IsEmpty(ListItem.Property(video)) + + 103 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + + + String.IsEmpty(ListItem.Property(watched)) + 951 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + 313 + 0 + + 0 + 16 + 510 + 30 + font12 + left + center + DF000000 + + + + 0 + 51 + 510 + 30 + font10 + left + center + 98000000 + + + + + + 802 + 0 + 200 + 100 + font12 + right + center + B8000000 + + + + !String.IsEmpty(ListItem.Property(progress)) + 103 + 91 + + 0 + 0 + 899 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 899 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + + + + + + + + 1128 + 33 + 10 + 879 + 101 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + + + + 201 + 0 + 0 + 1920 + 135 + + VisibleChange + ControlGroup(200).HasFocus(0) + false + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font12 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-playlists.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-playlists.xml new file mode 100644 index 000000000..7e5e9621e --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-playlists.xml @@ -0,0 +1,567 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + 0 + 115 + 101 + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 0 + 0 + 1920 + 360 + + 60 + 0 + 1800 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1920 + 390 + 200 + 301 + 200 + horizontal + 2 + + + + 40 + 40 + + 21 + 21 + + 0 + 0 + 238 + 238 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 238 + 238 + $INFO[ListItem.Thumb] + scale + + + -30 + 248 + 298 + 40 + font10 + center + FFFFFFFF + + + + -30 + 278 + 298 + 40 + font10 + center + FFFFFFFF + + + + + + + + + + 40 + 40 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(101) + -19 + -19 + 318 + 318 + script.plex/drop-shadow.png + + + 21 + 21 + + 0 + 0 + 238 + 238 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 238 + 238 + $INFO[ListItem.Thumb] + scale + + + -30 + 248 + 298 + 40 + font10 + center + FFFFFFFF + + + + -30 + 278 + 298 + 40 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(101) + 16 + 16 + 248 + 248 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(301).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + Conditional + 301 + 0 + 445 + 1920 + 360 + + Control.IsVisible(100) + 60 + 0 + 1800 + 2 + script.plex/white-square.png + 661F1F1F + + + 60 + 0 + 1800 + 80 + font12 + left + center + FFFFFFFF + + + + -21.5 + 30 + 1941.5 + 700 + 101 + 200 + horizontal + 2 + + + + 40 + 40 + + 41.5 + 25.5 + + 0 + 0 + 537 + 303 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 537 + 303 + $INFO[ListItem.Thumb] + scale + + + -30 + 313 + 597 + 40 + font10 + center + FFFFFFFF + + + + -30 + 343 + 597 + 40 + font10 + center + FFFFFFFF + + + + + + + + + + 40 + 40 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(301) + 1.5 + -15.5 + 617 + 383 + script.plex/drop-shadow.png + + + 41.5 + 25.5 + + 0 + 0 + 537 + 303 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 537 + 303 + $INFO[ListItem.Thumb] + scale + + + -30 + 313 + 597 + 40 + font10 + center + FFFFFFFF + + + + -30 + 343 + 597 + 40 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(301) + 36.5 + 20.5 + 547 + 313 + script.plex/home/selected.png + + + + + + + + + + + 201 + 0 + 0 + 1920 + 135 + + VisibleChange + ControlGroup(200).HasFocus(0) + false + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font12 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-plex_pass.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-plex_pass.xml new file mode 100644 index 000000000..d4c0be6d5 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-plex_pass.xml @@ -0,0 +1,39 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + + 0 + 0 + 1920 + 1080 + script.plex/sign_in/plexpass.jpg + + + + 1436 + 802 + 275 + 100 + 200 + font13 + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-posters-small.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-posters-small.xml new file mode 100644 index 000000000..bfd43ed7d --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-posters-small.xml @@ -0,0 +1,877 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + Conditional + 0 + 135 + 101 + + + VisibleChange + !Integer.IsGreater(Container(101).ListItem.Property(index),9) + String.IsEmpty(Window.Property(no.content)) + String.IsEmpty(Window.Property(no.content.filtered)) + !String.IsEmpty(Window.Property(initialized)) + 301 + 30 + -25 + 1000 + 145 + 200 + 101 + -20 + horizontal + 200 + true + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + String.IsEmpty(Window.Property(no.options)) | Player.HasAudio + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/chapters-focus.png + script.plex/buttons/chapters.png + + + + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 0 + -35 + 1920 + 1080 + + + 0 + 0 + 1800 + 1190 + 300 + 151 + 200 + vertical + 2 + 152 + + + + 55 + 137 + + 5 + 5 + + 0 + 0 + 144 + 213 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 144 + 213 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 203 + + 0 + 0 + 144 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 144 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 115 + 0 + 29 + 29 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 93 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 94 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 94 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + String.IsEmpty(ListItem.Property(subtitle)) + false + 0 + 218 + 144 + 72 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(subtitle)) + false + 0 + 218 + 144 + 72 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 137 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(101) + -40 + -40 + 234 + 316 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 144 + 213 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 144 + 213 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 203 + + 0 + 0 + 144 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 144 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 115 + 0 + 29 + 29 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 93 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 94 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 94 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + String.IsEmpty(ListItem.Property(subtitle)) + true + 0 + 218 + 144 + 72 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(subtitle)) + true + 0 + 218 + 144 + 30 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(101) + 0 + 0 + 154 + 225 + script.plex/home/selected.png + + + + + + + + + + + String.IsEqual(Window(10000).Property(script.plex.sort),titleSort) + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 151 + 1780 + 150 + 20 + 920 + + 0 + 0 + 34 + 1050 + 100 + 152 + 200 + vertical + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + Control.HasFocus(151) + 0 + 0 + + Control.HasFocus(151) + 0 + 0 + 34 + 34 + FFE5A00D + script.plex/white-outline-rounded.png + + + + + + + + + + 1860 + 150 + 12 + 910 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + + + Conditional + !String.IsEmpty(Window.Property(initialized)) + 201 + 0 + 0 + 1920 + 135 + + VisibleChange + ControlGroup(200).HasFocus(0) + Integer.IsGreater(Container(101).ListItem.Property(index),9) + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font10 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 211 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + String.IsEmpty(Window.Property(hide.filteroptions)) + 340 + 35 + 870 + 65 + right + 30 + horizontal + 204 + 210 + 50 + + !String.IsEqual(Window.Property(media.itemType),folder) + false + auto + 65 + font10 + A0FFFFFF + A0FFFFFF + A0FFFFFF + center + center + - + - + 0 + 0 + + + + !String.IsEqual(Window.Property(media.itemType),folder) + auto + 65 + font10 + A0FFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + String.IsEqual(Window.Property(subDir),1) | ![String.IsEqual(Window.Property(media),show) | String.IsEqual(Window.Property(media),movie)] + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 20 + 0 + + + + !String.IsEqual(Window.Property(subDir),1) + [String.IsEqual(Window.Property(media),show) | String.IsEqual(Window.Property(media),movie)] + auto + 65 + font12 + FFFFFFFF + FF000000 + FFFFFFFF + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + !String.IsEqual(Window.Property(media.itemType),folder) + auto + 65 + font10 + A0FFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + + 213 + 35 + 200 + 65 + font10 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(no.content)) + 0 + 465 + + false + 60 + 0 + 1800 + 35 + font13 + center + FFFFFFFF + + + + false + 60 + 60 + 1800 + 35 + font13 + center + FFCCCCCC + + + + + + !String.IsEmpty(Window.Property(no.content.filtered)) + 0 + 465 + + false + 60 + 0 + 1800 + 35 + font13 + center + FFFFFFFF + + + + false + 60 + 60 + 1800 + 35 + font13 + center + FFCCCCCC + + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-posters.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-posters.xml new file mode 100644 index 000000000..3dd674899 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-posters.xml @@ -0,0 +1,851 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + Conditional + 0 + 135 + 101 + + + VisibleChange + !Integer.IsGreater(Container(101).ListItem.Property(index),5) + String.IsEmpty(Window.Property(no.content)) + String.IsEmpty(Window.Property(no.content.filtered)) + !String.IsEmpty(Window.Property(initialized)) + 301 + 30 + -25 + 1000 + 145 + 200 + 101 + -20 + horizontal + 200 + true + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + String.IsEmpty(Window.Property(no.options)) | Player.HasAudio + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/chapters-focus.png + script.plex/buttons/chapters.png + + + + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 0 + -35 + 1920 + 1080 + + + 0 + 0 + 1800 + 1190 + 300 + 151 + 200 + vertical + 2 + 152 + + + + 55 + 137 + + 5 + 5 + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 199 + 0 + 45 + 45 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + false + 0 + 371 + 244 + 72 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 137 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(101) + -40 + -40 + 334 + 451 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 199 + 0 + 45 + 45 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + + true + 0 + 371 + 244 + 72 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(101) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + + + + String.IsEqual(Window(10000).Property(script.plex.sort),titleSort) + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 151 + 1780 + 150 + 20 + 920 + + 0 + 0 + 34 + 1050 + 100 + 152 + 200 + vertical + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + Control.HasFocus(151) + 0 + 0 + + Control.HasFocus(151) + 0 + 0 + 34 + 34 + FFE5A00D + script.plex/white-outline-rounded.png + + + + + + + + + + 1860 + 150 + 12 + 910 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + + + Conditional + 201 + 0 + 0 + 1920 + 135 + !String.IsEmpty(Window.Property(initialized)) + + VisibleChange + ControlGroup(200).HasFocus(0) + Integer.IsGreater(Container(101).ListItem.Property(index),5) + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font10 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 211 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + String.IsEmpty(Window.Property(hide.filteroptions)) + 340 + 35 + 870 + 65 + right + 30 + horizontal + 204 + 210 + 50 + + !String.IsEqual(Window.Property(media.itemType),folder) + false + auto + 65 + font10 + A0FFFFFF + A0FFFFFF + A0FFFFFF + center + center + - + - + 0 + 0 + + + + !String.IsEqual(Window.Property(media.itemType),folder) + auto + 65 + font10 + A0FFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + String.IsEqual(Window.Property(subDir),1) | ![String.IsEqual(Window.Property(media),show) | String.IsEqual(Window.Property(media),movie)] + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 20 + 0 + + + + !String.IsEqual(Window.Property(subDir),1) + [String.IsEqual(Window.Property(media),show) | String.IsEqual(Window.Property(media),movie)] + auto + 65 + font12 + FFFFFFFF + FF000000 + FFFFFFFF + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + !String.IsEqual(Window.Property(media.itemType),folder) + auto + 65 + font10 + A0FFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + + 213 + 35 + 200 + 65 + font10 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(no.content)) + 0 + 465 + + false + 60 + 0 + 1800 + 35 + font13 + center + FFFFFFFF + + + + false + 60 + 60 + 1800 + 35 + font13 + center + FFCCCCCC + + + + + + !String.IsEmpty(Window.Property(no.content.filtered)) + 0 + 465 + + false + 60 + 0 + 1800 + 35 + font13 + center + FFFFFFFF + + + + false + 60 + 60 + 1800 + 35 + font13 + center + FFCCCCCC + + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-pre_play.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-pre_play.xml new file mode 100644 index 000000000..9b7c28131 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-pre_play.xml @@ -0,0 +1,1494 @@ + + + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + Conditional + + + + + + + + + + + + + + 0 + 155 + 101 + + + VisibleChange + !String.IsEmpty(Window.Property(initialized)) + 302 + 428 + 410 + 1000 + 145 + 200 + 400 + -50 + horizontal + 200 + true + + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/info-focus.png + script.plex/buttons/info.png + + + + + String.IsEmpty(Window.Property(unavailable)) + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + !String.IsEmpty(Window.Property(trailer.button)) + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/trailer-focus.png + script.plex/buttons/trailer.png + + + + !String.IsEmpty(Window.Property(media.multiple)) + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/media-focus.png + script.plex/buttons/media.png + + + + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/settings-focus.png + script.plex/buttons/settings.png + + + + Focus + UnFocus + + 0 + 0 + 176 + 140 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + + 0 + 0 + 1920 + 600 + + !String.IsEmpty(Window.Property(preview.no)) + + 60 + 0 + 347 + 518 + script.plex/thumb_fallbacks/movie.png + WindowOpen + scale + + + 60 + 0 + 347 + 518 + $INFO[Window.Property(thumb)] + scale + + + !String.IsEmpty(Window.Property(unwatched)) + 359 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + + + !String.IsEmpty(Window.Property(preview.yes)) + 60 + 0 + + 0 + 0 + 347 + 315 + script.plex/thumb_fallbacks/show.png + WindowOpen + scale + + + 0 + 323 + 347 + 195 + script.plex/white-square.png + scale + + + + 0 + 0 + 347 + 315 + $INFO[Window.Property(thumb)] + scale + + + 0 + 323 + 347 + 195 + $INFO[Window.Property(preview)] + scale + + + + 466 + 0 + 1360 + 60 + left + 0 + horizontal + true + + auto + 60 + font13 + left + FFFFFFFF + + + + !String.IsEmpty(Window.Property(remainingTime)) + 10 + 6 + auto + 34 + font12 + center + center + FFE5A00D + FFE5A00D + 15 + script.plex/white-square-rounded-top-padded.png + script.plex/white-square-rounded-top-padded.png + + + + + 466 + 68 + 1360 + 34 + left + 0 + horizontal + true + + auto + 34 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Window.Property(rating.stars)) + auto + 34 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Window.Property(rating.stars)) + 6 + 134 + 22 + script.plex/stars/$INFO[Window.Property(rating.stars)].png + + + !String.IsEmpty(Window.Property(video.res)) + 10 + auto + 34 + font12 + center + center + FFFFFFFF + FFFFFFFF + 15 + script.plex/white-square-rounded-top-padded.png + script.plex/white-square-rounded-top-padded.png + + + + !String.IsEmpty(Window.Property(unavailable)) + 10 + auto + 34 + font12 + center + center + FFFFFFFF + FFFFFFFF + 15 + script.plex/white-square-rounded-top-padded.png + script.plex/white-square-rounded-top-padded.png + + + + + + !String.IsEmpty(Window.Property(rating)) + 1560 + 4 + 300 + 32 + right + 15 + horizontal + true + + 2 + 63 + 30 + $INFO[Window.Property(rating.image)] + keep + + + auto + 30 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Window.Property(rating2)) + 2 + 40 + 30 + $INFO[Window.Property(rating2.image)] + keep + + + !String.IsEmpty(Window.Property(rating2)) + auto + 30 + font12 + left + FFFFFFFF + + + + + + !String.IsEmpty(Window.Property(directors)) + 466 + 130 + 1360 + 30 + font12 + left + 99FFFFFF + + + + !String.IsEmpty(Window.Property(writers)) + 466 + 165 + 1360 + 30 + font12 + left + 99FFFFFF + + + + 466 + 223 + 1360 + 34 + left + 15 + horizontal + true + + !String.IsEmpty(Window.Property(audio)) + auto + 34 + font12 + center + center + FFFFFFFF + FFFFFFFF + 15 + script.plex/white-square-rounded-top-padded.png + script.plex/white-square-rounded-top-padded.png + + + + auto + 34 + font12 + left + center + FFFFFFFF + + + + !String.IsEmpty(Window.Property(subtitles)) + 30 + auto + 34 + font12 + center + center + FFFFFFFF + FFFFFFFF + 15 + script.plex/white-square-rounded-top-padded.png + script.plex/white-square-rounded-top-padded.png + + + + !String.IsEmpty(Window.Property(subtitles)) + auto + 34 + font12 + left + center + FFFFFFFF + + + + + 466 + 290 + 1360 + 102 + font12 + left + FFFFFFFF + + + + WindowOpen + -1 + 557 + 1 + 8 + script.plex/white-square.png + FFCC7B19 + + + !Control.IsVisible(500) + 0 + 565 + 1920 + 2 + script.plex/white-square.png + A0000000 + + + + + 0 + 565 + 1920 + 1800 + + 300 + 0 + + + + Integer.IsGreater(Container(400).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 400 + 1920 + 446 + + 60 + 20 + 1000 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 36 + 1920 + 410 + 300 + 401 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + script.plex/thumb_fallbacks/role.png + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + 0 + 253 + + false + 0 + 0 + 244 + 60 + font10 + center + AAFFFFFF + + + + false + 0 + 30 + 244 + 60 + font10 + center + AAFFFFFF + + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(403) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + script.plex/thumb_fallbacks/role.png + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + 0 + 253 + + Control.HasFocus(400) + 0 + 0 + 244 + 60 + font10 + center + AAFFFFFF + + + + Control.HasFocus(400) + 0 + 30 + 244 + 60 + font10 + center + AAFFFFFF + + + + + + Control.HasFocus(400) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + + + Integer.IsGreater(Container(401).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 401 + 1920 + 446 + + 60 + 20 + 1000 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 36 + 1920 + 410 + 400 + 402 + 200 + horizontal + 4 + + + + 55 + 61 + + 0 + 0 + 520 + 310 + script.plex/white-square.png + 60000000 + + + 20 + 20 + + 0 + 0 + + 10 + -5 + 70 + 70 + script.plex/reviews/$INFO[ListItem.Thumb].png + + + false + 100 + 0 + 400 + 30 + font10 + left + DDFFFFFF + + + + false + 100 + 30 + 400 + 30 + font10 + left + 66FFFFFF + + + + + 0 + 80 + 480 + 190 + font10 + left + AAFFFFFF + + + + + + + + + + 55 + 61 + + 0 + 0 + 520 + 310 + script.plex/white-square.png + 80000000 + + + 20 + 20 + + 0 + 0 + + 10 + -5 + 70 + 70 + script.plex/reviews/$INFO[ListItem.Thumb].png + + + false + 100 + 0 + 400 + 30 + font10 + left + DDFFFFFF + + + + false + 100 + 30 + 400 + 30 + font10 + left + 66FFFFFF + + + + + 0 + 80 + 480 + 190 + font10 + left + DDFFFFFF + + Control.HasFocus(401) + + + + Control.HasFocus(401) + -5 + -5 + 530 + 320 + script.plex/home/selected.png + + + + + + + + + + Integer.IsGreater(Container(402).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 360 + 1920 + + 0 + 0 + 1920 + 360 + script.plex/white-square.png + 40000000 + + + 60 + 0 + 800 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 18 + 1920 + 430 + 401 + 403 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + 0 + 180 + 299 + 60 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(402) + -40 + -40 + 389 + 258 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + 0 + 180 + 299 + 60 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(402) + 0 + 0 + 309 + 178 + script.plex/home/selected.png + + + + + + + + + + + Integer.IsGreater(Container(403).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 403 + 1920 + 520 + + !String.IsEmpty(Window.Property(divider.403)) + 60 + 0 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 16 + 1920 + 520 + 402 + false + false + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 369 + 244 + 38 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(403) + -40 + -40 + 324 + 441 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + Control.HasFocus(403) + 0 + 369 + 244 + 38 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(403) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + + + + + + + + + + Conditional + 201 + 0 + 0 + 1920 + 135 + + VisibleChange + ControlGroup(200).HasFocus(0) + !String.IsEmpty(Window.Property(on.extras)) + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-pre_signin.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-pre_signin.xml new file mode 100644 index 000000000..9c8b5d391 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-pre_signin.xml @@ -0,0 +1,39 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + + 0 + 0 + 1920 + 1080 + script.plex/sign_in/pre-signin.jpg + + + + 1437 + 801 + 275 + 104 + 200 + font13 + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-refresh_code.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-refresh_code.xml new file mode 100644 index 000000000..482622389 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-refresh_code.xml @@ -0,0 +1,39 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + + 0 + 0 + 1920 + 1080 + script.plex/sign_in/refresh-code.jpg + + + + 1383 + 802 + 335 + 102 + 200 + font13 + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-search.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-search.xml new file mode 100644 index 000000000..3488272af --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-search.xml @@ -0,0 +1,8838 @@ + + SetProperty(dropdown,1) + + 1 + 0 + 0 + + + + + + 0 + 0 + 564 + 1080 + script.plex/white-square.png + + + 0 + 135 + 564 + 810 + String.IsEmpty(Window.Property(hide.kbd)) + script.plex/white-square.png + + + 0 + 135 + 564 + 248 + !String.IsEmpty(Window.Property(hide.kbd)) + script.plex/white-square.png + + + 0 + 945 + 564 + 135 + String.IsEmpty(Window.Property(hide.kbd)) + script.plex/white-square.png + + + 0 + 383 + 564 + 697 + !String.IsEmpty(Window.Property(hide.kbd)) + script.plex/white-square.png + + + + + Focus + UnFocus + 60 + 47.5 + 40 + 40 + 900 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + Close + + + + false + 160 + 35 + 500 + 65 + font12 + left + center + FFFFFFFF + + + + + + + 60 + 186 + 459 + 60 + + + 0 + 0 + + 0 + 0 + 151 + 60 + script.plex/white-square.png + + + String.IsEqual(Window.Property(search.section),all) + + 0 + 0 + 151 + 60 + script.plex/white-square.png + + + false + 0 + 0 + 151 + 60 + font12 + center + center + FF000000 + + + + + false + !String.IsEqual(Window.Property(search.section),all) + 0 + 0 + 151 + 60 + font12 + center + center + FFFFFFFF + + + + + 154 + 0 + + 0 + 0 + 74 + 60 + script.plex/white-square.png + + + String.IsEqual(Window.Property(search.section),movie) + 0 + 0 + 74 + 60 + script.plex/white-square.png + + + + 231 + 0 + + 0 + 0 + 74 + 60 + script.plex/white-square.png + + + String.IsEqual(Window.Property(search.section),show) + 0 + 0 + 74 + 60 + script.plex/white-square.png + + + + 308 + 0 + + 0 + 0 + 74 + 60 + script.plex/white-square.png + + + String.IsEqual(Window.Property(search.section),artist) + 0 + 0 + 74 + 60 + script.plex/white-square.png + + + + 384 + 0 + + 0 + 0 + 74 + 60 + script.plex/white-square.png + + + String.IsEqual(Window.Property(search.section),photo) + 0 + 0 + 74 + 60 + script.plex/white-square.png + + + + + 901 + + 0 + 0 + + Focus + UnFocus + + -40 + -40 + 231 + 140 + 902 + 999 + 650 + font12 + center + center + FF000000 + 00000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 154 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 140 + 901 + 903 + 999 + 650 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 231 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 140 + 902 + 904 + 999 + 650 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 308 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 140 + 903 + 905 + 999 + 650 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 385 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 140 + 904 + 3000 + 999 + 650 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + + + 154 + 0 + + Conditional + 0 + 0 + 74 + 60 + + 26 + 20 + 22 + 20 + script.plex/home/type/movie.png + + + + + 231 + 0 + + Conditional + 0 + 0 + 74 + 60 + + 26 + 20 + 24 + 20 + script.plex/home/type/show.png + + + + + 308 + 0 + + Conditional + 0 + 0 + 74 + 60 + + 26 + 20 + 18 + 20 + script.plex/home/type/artist.png + + + + + 385 + 0 + + Conditional + 0 + 0 + 74 + 60 + + 26 + 20 + 22 + 20 + script.plex/home/type/photo.png + + + + + + + + 60 + 276 + 459 + 60 + + 0 + 0 + 459 + 60 + script.plex/white-square.png + + + 0 + 0 + 459 + 60 + left + center + 900 + 1001 + 3000 + 00000000 + + + font13 + 30 + script.plex/home/selected.png + - + no + + + false + 30 + 0 + 399 + 60 + left + center + FFFFFFFF + font13 + + + + + + + 60 + 360 + 459 + 439 + String.IsEmpty(Window.Property(hide.kbd)) + + + + 0 + 0 + + 0 + 0 + 74 + 74 + script.plex/white-square.png + + + 77 + 0 + 74 + 74 + script.plex/white-square.png + + + 154 + 0 + 74 + 74 + script.plex/white-square.png + + + 231 + 0 + 74 + 74 + script.plex/white-square.png + + + 308 + 0 + 74 + 74 + script.plex/white-square.png + + + 385 + 0 + 74 + 74 + script.plex/white-square.png + + + + + 0 + 77 + + 0 + 0 + 74 + 74 + script.plex/white-square.png + + + 77 + 0 + 74 + 74 + script.plex/white-square.png + + + 154 + 0 + 74 + 74 + script.plex/white-square.png + + + 231 + 0 + 74 + 74 + script.plex/white-square.png + + + 308 + 0 + 74 + 74 + script.plex/white-square.png + + + 385 + 0 + 74 + 74 + script.plex/white-square.png + + + + + 0 + 154 + + 0 + 0 + 74 + 74 + script.plex/white-square.png + + + 77 + 0 + 74 + 74 + script.plex/white-square.png + + + 154 + 0 + 74 + 74 + script.plex/white-square.png + + + 231 + 0 + 74 + 74 + script.plex/white-square.png + + + 308 + 0 + 74 + 74 + script.plex/white-square.png + + + 385 + 0 + 74 + 74 + script.plex/white-square.png + + + + + 0 + 231 + + 0 + 0 + 74 + 74 + script.plex/white-square.png + + + 77 + 0 + 74 + 74 + script.plex/white-square.png + + + 154 + 0 + 74 + 74 + script.plex/white-square.png + + + 231 + 0 + 74 + 74 + script.plex/white-square.png + + + 308 + 0 + 74 + 74 + script.plex/white-square.png + + + 385 + 0 + 74 + 74 + script.plex/white-square.png + + + + + 0 + 308 + + 0 + 0 + 74 + 74 + script.plex/white-square.png + + + 77 + 0 + 74 + 74 + script.plex/white-square.png + + + 154 + 0 + 74 + 74 + script.plex/white-square.png + + + 231 + 0 + 74 + 74 + script.plex/white-square.png + + + 308 + 0 + 74 + 74 + script.plex/white-square.png + + + 385 + 0 + 74 + 74 + script.plex/white-square.png + + + + + 0 + 385 + + 0 + 0 + 74 + 74 + script.plex/white-square.png + + + 77 + 0 + 74 + 74 + script.plex/white-square.png + + + 154 + 0 + 74 + 74 + script.plex/white-square.png + + + 231 + 0 + 74 + 74 + script.plex/white-square.png + + + 308 + 0 + 74 + 74 + script.plex/white-square.png + + + 385 + 0 + 74 + 74 + script.plex/white-square.png + + + + + + 0 + 0 + + 0 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 650 + 1007 + 1002 + 1006 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 77 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 650 + 1008 + 1003 + 1001 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 154 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 650 + 1009 + 1004 + 1002 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 231 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 650 + 1010 + 1005 + 1003 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 308 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 650 + 1011 + 1006 + 1004 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 385 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 650 + 1012 + 3000 + 1005 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + + + 0 + 77 + + 0 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1001 + 1013 + 1008 + 1012 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 77 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1002 + 1014 + 1009 + 1007 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 154 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1003 + 1015 + 1010 + 1008 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 231 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1004 + 1016 + 1011 + 1009 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 308 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1005 + 1017 + 1012 + 1010 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 385 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1006 + 1018 + 3000 + 1011 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + + + 0 + 154 + + 0 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1007 + 1019 + 1014 + 1018 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 77 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1008 + 1020 + 1015 + 1013 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 154 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1009 + 1021 + 1016 + 1014 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 231 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1010 + 1022 + 1017 + 1015 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 308 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1011 + 1023 + 1018 + 1016 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 385 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1012 + 1024 + 3000 + 1017 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + + + 0 + 231 + + 0 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1013 + 1025 + 1020 + 1024 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 77 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1014 + 1026 + 1021 + 1019 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 154 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1015 + 1027 + 1022 + 1020 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 231 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1016 + 1028 + 1023 + 1021 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 308 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1017 + 1029 + 1024 + 1022 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 385 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1018 + 1030 + 3000 + 1023 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + + + 0 + 308 + + 0 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1019 + 1031 + 1026 + 1030 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 77 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1020 + 1032 + 1027 + 1025 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 154 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1021 + 1033 + 1028 + 1026 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 231 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1022 + 1034 + 1029 + 1027 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 308 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1023 + 1035 + 1030 + 1028 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 385 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1024 + 1036 + 3000 + 1029 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + + + 0 + 385 + + 0 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1025 + 951 + 1032 + 1036 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 77 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1026 + 951 + 1033 + 1031 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 154 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1027 + 952 + 1034 + 1032 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 231 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1028 + 952 + 1035 + 1033 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 308 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1029 + 953 + 1036 + 1034 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 385 + 0 + + Focus + UnFocus + + -40 + -40 + 154 + 154 + 1030 + 953 + 3000 + 1035 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + + + + + 60 + 840 + 459 + 60 + String.IsEmpty(Window.Property(hide.kbd)) + + 0 + 0 + 151 + 60 + script.plex/white-square.png + + + 154 + 0 + 151 + 60 + script.plex/white-square.png + + + 308 + 0 + 151 + 60 + script.plex/white-square.png + + + + 0 + 0 + + Focus + UnFocus + + -40 + -40 + 231 + 140 + 953 + 952 + 1031 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 154 + 0 + + Focus + UnFocus + + -40 + -40 + 231 + 140 + 951 + 953 + 1033 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + 308 + 0 + + Focus + UnFocus + + -40 + -40 + 231 + 140 + 952 + 3000 + 1036 + font12 + center + center + FF000000 + script.plex/white-square-rounded-with-shadow.png + - + + + + + + + Visible + !String.IsEmpty(Window.Property(searching)) + 0 + 993 + + false + 420 + 0 + 434 + 39 + font12 + right + center + FFFFFFFF + + + + 480 + 0 + + !String.IsEmpty(Window.Property(searching)) + Visible + 0 + 0 + 39 + 39 + script.plex/indicators/spinner.png + + + + + !String.IsEmpty(Window.Property(no.results)) + String.IsEmpty(Window.Property(searching)) + 0 + 993 + + false + 0 + 0 + 564 + 39 + font12 + center + center + FFFFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2000 + 564 + 20 + 2130 + 5540 + 20 + vertical + true + 200 + + + 2100 + Integer.IsGreater(Container(2100).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2101 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2100) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2100) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2100 + 2102 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2101) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2101) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2102).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2101 + 2103 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2102) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2102) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2103).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2102 + 2104 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2103) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2103) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2104).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2103 + 2105 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2104) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2104) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2105).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2104 + 2106 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2105) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2105) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2106).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2105 + 2107 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2106) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2106) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2107).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2106 + 2108 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2107) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2107) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2108).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2107 + 2109 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2108) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2108) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2109).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2108 + 2110 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2109) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2109) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2110).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2109 + 2111 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2110) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2110) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2111).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2110 + 2112 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2111) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2111) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2112).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2111 + 2113 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2112) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2112) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2113).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2112 + 2114 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2113) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2113) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2114).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2113 + 2115 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2114) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2114) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2115).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2114 + 2116 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2115) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2115) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2116).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2115 + 2117 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2116) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2116) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2117).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2116 + 2118 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2117) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2117) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2118).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2117 + 2119 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2118) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2118) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2119).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2118 + 2120 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2119) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2119) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2120).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2119 + 2121 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2120) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2120) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2121).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2120 + 2122 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2121) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2121) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2122).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2121 + 2123 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2122) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2122) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2123).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2122 + 2124 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2123) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2123) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2124).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2123 + 2125 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2124) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2124) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2125).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2124 + 2126 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2125) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2125) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2126).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2125 + 2127 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2126) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2126) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2127).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2126 + 2128 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2127) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2127) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2128).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2127 + 2129 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2128) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2128) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2129).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2128 + 2130 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2129) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2129) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2130).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2129 + 2131 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2130) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2130) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2131).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2130 + 2132 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2131) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2131) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2132).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2131 + 2133 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2132) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2132) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2133).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2132 + 2134 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2133) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2133) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2134).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2133 + 2135 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2134) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2134) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2135).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2134 + 2136 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2135) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2135) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2136).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2135 + 2137 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2136) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2136) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2137).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2136 + 2138 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2137) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2137) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2138).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2137 + 2139 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2138) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2138) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2139).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2138 + 2140 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2139) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2139) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2140).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2139 + 2141 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2140) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2140) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2141).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2140 + 2142 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2141) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2141) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2142).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2141 + 2143 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2142) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2142) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2143).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2142 + 2144 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2143) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2143) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + Integer.IsGreater(Container(2144).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2143 + 2145 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2144) + -40 + -40 + 270 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 180 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 180 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 180 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2144) + 0 + 0 + 190 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2145).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2144 + 2146 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2145) + -40 + -40 + 360 + 360 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 270 + 270 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 270 + 270 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 280 + 270 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2145) + 0 + 0 + 280 + 280 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2146).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 320 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2145 + 2147 + 200 + horizontal + 4 + + + + 55 + 64 + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 64 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2146) + -40 + -40 + 390 + 259 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 300 + 169 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 300 + 169 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 179 + 300 + 35 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(2146) + 0 + 0 + 310 + 179 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(2147).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 1356 + 420 + + 60 + 0 + 1236 + 2 + script.plex/white-square.png + FF282828 + + + false + 60 + 0 + 1000 + 87 + font12 + left + center + FFFFFFFF + + + + 0 + 30 + 1356 + 440 + 899 + 2146 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(2147) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 254 + 244 + 35 + font10 + center + FFFFFFFF + + + + 0 + 282 + 244 + 35 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(2147) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + + \ No newline at end of file diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-seasons.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-seasons.xml new file mode 100644 index 000000000..c86327de0 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-seasons.xml @@ -0,0 +1,1361 @@ + + + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + Conditional + + + + + + + + + + + + + + 0 + 155 + 101 + + + VisibleChange + !String.IsEmpty(Window.Property(initialized)) + 302 + 440 + 445 + 1000 + 145 + 200 + 400 + -20 + horizontal + 200 + true + + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/info-focus.png + script.plex/buttons/info.png + + + + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + + 0 + 0 + 1920 + 600 + + 60 + 0 + 347 + 518 + script.plex/thumb_fallbacks/movie.png + WindowOpen + scale + + + 60 + 0 + 347 + 518 + $INFO[Window.Property(thumb)] + scale + + + + 466 + 0 + 1360 + 60 + font13 + left + FFFFFFFF + + + + 466 + 70 + 1360 + 30 + left + 0 + horizontal + true + + auto + 30 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Window.Property(rating.stars)) + auto + 30 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Window.Property(rating.stars)) + 4 + 134 + 22 + script.plex/stars/$INFO[Window.Property(rating.stars)].png + + + + + !String.IsEmpty(Window.Property(rating)) + 1660 + 70 + 200 + 32 + right + 15 + horizontal + true + + 2 + 63 + 30 + $INFO[Window.Property(rating.image)] + keep + + + auto + 30 + font12 + left + FFFFFFFF + + + + !String.IsEmpty(Window.Property(rating2)) + 2 + 63 + 30 + $INFO[Window.Property(rating2.image)] + keep + + + !String.IsEmpty(Window.Property(rating2)) + auto + 30 + font12 + left + FFFFFFFF + + + + + + + !String.IsEmpty(Window.Property(directors)) + 466 + 130 + 1360 + 30 + font12 + left + 99FFFFFF + + + + !String.IsEmpty(Window.Property(writers)) + 466 + 165 + 1360 + 30 + font12 + left + 99FFFFFF + + + + + 466 + 234 + 1360 + 179 + font12 + left + FFFFFFFF + + + + WindowOpen + -1 + 557 + 1 + 8 + script.plex/white-square.png + FFCC7B19 + + + !Control.IsVisible(500) + 0 + 565 + 1920 + 2 + script.plex/white-square.png + A0000000 + + + + + 0 + 565 + 1920 + 1600 + + 300 + 0 + + + Integer.IsGreater(Container(400).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 360 + 1920 + + 0 + 0 + 1920 + 360 + script.plex/white-square.png + 40000000 + + + 0 + 14 + 1920 + 430 + 300 + 401 + 200 + horizontal + 4 + + + + 55 + 29 + + 5 + 5 + + 0 + 0 + 158 + 236 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 158 + 236 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 113 + 0 + 45 + 40 + script.plex/white-square.png + FF000000 + + + 114 + 0 + 44 + 39 + script.plex/white-square.png + FFCC7B19 + + + 114 + 0 + 44 + 39 + font10 + center + center + FF000000 + + + + + false + 0 + 240 + 158 + 54 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 29 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(400) + -40 + -40 + 248 + 326 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 158 + 236 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 158 + 236 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 113 + 0 + 45 + 40 + script.plex/white-square.png + FF000000 + + + 114 + 0 + 44 + 39 + script.plex/white-square.png + FFCC7B19 + + + 114 + 0 + 44 + 39 + font10 + center + center + FF000000 + + + + + Control.HasFocus(400) + 0 + 240 + 158 + 54 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(400) + 0 + 0 + 168 + 246 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(401).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 360 + 1920 + + 60 + 0 + 800 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 18 + 1920 + 430 + 400 + 402 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + false + 0 + 175 + 299 + 60 + font12 + center + FFFFFFFF + + + + false + 0 + 210 + 299 + 60 + font12 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(401) + -40 + -40 + 389 + 258 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + + Control.HasFocus(401) + 0 + 175 + 299 + 60 + font12 + center + FFFFFFFF + + + + Control.HasFocus(401) + 0 + 210 + 299 + 60 + font12 + center + FFFFFFFF + + + + + + Control.HasFocus(401) + 0 + 0 + 309 + 178 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(402).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 402 + 1920 + 520 + + !String.IsEmpty(Window.Property(divider.402)) + 60 + 0 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 16 + 1920 + 520 + 401 + 403 + false + false + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 369 + 244 + 38 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(402) + -40 + -40 + 324 + 441 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + Control.HasFocus(402) + 0 + 369 + 244 + 38 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(402) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + + + + + + + Integer.IsGreater(Container(403).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 403 + 1920 + 410 + + !String.IsEmpty(Window.Property(divider.403)) + 60 + 20 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 20 + 1000 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 36 + 1920 + 410 + 402 + 403 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + script.plex/thumb_fallbacks/role.png + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + 0 + 253 + 244 + 90 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(403) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + script.plex/thumb_fallbacks/role.png + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + 0 + 253 + 244 + 90 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(403) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + + + Conditional + 201 + 0 + 0 + 1920 + 135 + + VisibleChange + ControlGroup(200).HasFocus(0) + !String.IsEmpty(Window.Property(on.extras)) + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-seek_dialog.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-seek_dialog.xml new file mode 100644 index 000000000..d85f26fc4 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-seek_dialog.xml @@ -0,0 +1,1106 @@ + + + + 1 + 0 + 0 + + 100 + 800 + + + [!String.IsEmpty(Window.Property(show.OSD)) | Window.IsVisible(seekbar) | !String.IsEmpty(Window.Property(button.seek))] + !Window.IsVisible(osdvideosettings) + !Window.IsVisible(osdaudiosettings) + !Window.IsVisible(osdsubtitlesettings) + !Window.IsVisible(subtitlesearch) + !Window.IsActive(playerprocessinfo) + !Window.IsActive(selectdialog) + Hidden + + String.IsEmpty(Window.Property(settings.visible)) + [Window.IsVisible(seekbar) | Window.IsVisible(videoosd) | Player.ShowInfo] + Hidden + 0 + 0 + + 0 + 0 + 1920 + 1080 + script.plex/player-fade.png + FF080808 + + + + + 0 + 0 + + 0 + 0 + 1920 + 140 + script.plex/white-square.png + A0000000 + + + 0 + 940 + 1920 + 140 + script.plex/white-square.png + A0000000 + + + + + 0 + 40 + + !String.IsEmpty(Window.Property(is.show)) + 60 + 0 + 1500 + 60 + font13 + left + center + FFFFFFFF + + + + String.IsEmpty(Window.Property(is.show)) + 60 + 0 + 1500 + 60 + font13 + left + center + FFFFFFFF + + + + 1860 + 0 + 300 + 60 + font12 + right + center + FFFFFFFF + + + + + + 0 + 965 + + !String.IsEmpty(Window.Property(direct.play)) + 60 + 0 + 1000 + 60 + font13 + left + center + FFFFFFFF + + + + String.IsEmpty(Window.Property(direct.play)) + 60 + 0 + 1000 + 60 + font13 + left + center + FFFFFFFF + + + + Player.IsTempo + 60 + 40 + 1000 + 60 + font13 + left + center + A0FFFFFF + + + + !String.IsEmpty(Window.Property(direct.play)) + 1860 + 0 + 800 + 60 + font13 + right + center + FFFFFFFF + + + + String.IsEmpty(Window.Property(direct.play)) + 1860 + 0 + 800 + 60 + font13 + right + center + FFFFFFFF + + + + !String.IsEmpty(Window.Property(media.show_ends)) + !String.IsEmpty(Window.Property(direct.play)) + 1860 + 40 + 800 + 60 + font13 + right + center + A0FFFFFF + + + + !String.IsEmpty(Window.Property(media.show_ends)) + String.IsEmpty(Window.Property(direct.play)) + 1860 + 40 + 800 + 60 + font13 + right + center + A0FFFFFF + + + + Player.Paused + String.IsEmpty(Window.Property(show.OSD)) + Visible + 0 + 20 + 1920 + 60 + font13 + center + center + FFCC7B19 + + + + + + 0 + 940 + + 0 + 0 + 1920 + 10 + script.plex/white-square.png + A0000000 + + + !String.IsEmpty(Window.Property(show.buffer)) + 0 + 2 + 1 + 6 + script.plex/white-square.png + EE4E4842 + + + 0 + 2 + 1 + 6 + script.plex/white-square.png + FFAC5B00 + + + Control.HasFocus(100) | !String.IsEmpty(Window.Property(button.seek)) + 0 + 2 + 1 + 6 + script.plex/white-square.png + FFE5A00D + + + + + String.IsEmpty(Window.Property(show.OSD)) + 0 + 0 + 1920 + 1080 + - + - + + SetProperty(show.OSD,1) + + + + + 0 + 350 + !String.IsEmpty(Window.Property(show.PPI)) + String.IsEmpty(Window.Property(settings.visible)) + String.IsEmpty(Window.Property(playlist.visible)) + Visible + Hidden + + 10 + -220 + 10 + 420 + buttons/dialogbutton-nofo.png + + + 52 + -184 + 1786 + 350 + horizontal + 10 + + 0 + 0 + 793 + + 793 + 50 + bottom + + font14 + black + Player.HasVideo + + + 793 + 50 + bottom + + font14 + black + Player.HasVideo + + + 793 + 50 + bottom + + font14 + black + Player.HasVideo + + + 793 + 50 + bottom + + font14 + black + Player.HasVideo + + + 793 + 50 + bottom + + + font14 + black + + + 793 + 50 + bottom + + font14 + black + + + + 0 + 0 + 993 + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Status)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Mode)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Container)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Video)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Audio)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Subtitles)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.User)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + String.IsEmpty(Window.Property(ppi.Buffered)) + + + 893 + 50 + bottom + + font14 + black + Player.HasVideo + !String.IsEmpty(Window.Property(ppi.Buffered)) + + + + + 52 + 120 + 1786 + 50 + bottom + + font14 + black + + + + !String.IsEmpty(Window.Property(show.OSD)) + !Window.IsVisible(osdvideosettings) + !Window.IsVisible(osdaudiosettings) + !Window.IsVisible(osdsubtitlesettings) + !Window.IsVisible(subtitlesearch) + !Window.IsActive(playerprocessinfo) + !Window.IsActive(selectdialog) + Hidden + + !String.IsEmpty(Window.Property(has.bif)) + [Control.HasFocus(100) | Control.HasFocus(501) | !String.IsEmpty(Window.Property(button.seek))] + Visible + 0 + 752 + + 0 + 0 + 324 + 184 + script.plex/white-square.png + FF000000 + + + 2 + 2 + 320 + 180 + 10 + $INFO[Window.Property(bif.image)] + + + + + 406 + + 360 + 964 + 1200 + + 124 + center + 100 + -40 + horizontal + 200 + true + + !String.IsEmpty(Window.Property(nav.repeat)) + Conditional + Conditional + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 402 + 412 + font12 + - + - + + + + !Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-one.png + + + + Control.HasFocus(401) + + !Playlist.IsRepeatOne + !Playlist.IsRepeat + String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-focus.png + + + Playlist.IsRepeat | !String.IsEmpty(Window.Property(pq.repeat)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-focus.png + + + Playlist.IsRepeatOne | !String.IsEmpty(Window.Property(pq.repeat.one)) + 0 + 0 + 125 + 101 + script.plex/buttons/repeat-one-focus.png + + + + + + !String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + !String.IsEmpty(Window.Property(pq.shuffled)) + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + false + String.IsEmpty(Window.Property(has.playlist)) + !String.IsEmpty(Window.Property(nav.shuffle)) + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/settings-focus.png + script.plex/buttons/settings.png + + + + + + !String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/skip-forward-focus.png + script.plex/buttons/skip-forward.png + + + + + Conditional + Conditional + 125 + 101 + + + 0 + 0 + 125 + 101 + 100 + 407 + 405 + font12 + - + - + + PlayerControl(Play) + + + !Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/pause.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/play.png + + + + Control.HasFocus(406) + + !Player.Paused + !Player.Forwarding + !Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/pause-focus.png + + + Player.Paused | Player.Forwarding | Player.Rewinding + 0 + 0 + 125 + 101 + script.plex/buttons/play-focus.png + + + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/stop-focus.png + script.plex/buttons/stop.png + + + + !String.IsEmpty(Window.Property(nav.ffwdrwd)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/skip-forward-focus.png + script.plex/buttons/skip-forward.png + + + + !String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + !String.IsEmpty(Window.Property(nav.prevnext)) + 0 + 0 + 125 + 101 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + + + + + + [!String.IsEmpty(Window.Property(pq.hasnext)) | !String.IsEmpty(Window.Property(pq.hasprev))] + !String.IsEmpty(Window.Property(nav.playlist)) + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/pqueue-focus.png + script.plex/buttons/pqueue.png + + + + false + String.IsEmpty(Window.Property(pq.hasnext)) + String.IsEmpty(Window.Property(pq.hasprev)) + !String.IsEmpty(Window.Property(nav.playlist)) + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/pqueue-focus.png + script.plex/buttons/pqueue.png + + + + !String.IsEmpty(Window.Property(nav.quick_subtitles)) + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/subtitle-focus.png + script.plex/buttons/subtitle.png + + + + + + 0 + 940 + + + 0 + 0 + 1920 + 10 + 501 + 400 + - + - + + + + + Conditional + + + Conditional + + + String.IsEmpty(Window.Property(mouse.mode)) + String.IsEmpty(Window.Property(hide.bigseek)) + [Control.HasFocus(501) | Control.HasFocus(100)] + [!String.IsEmpty(Window.Property(show.chapters)) | String.IsEmpty(Window.Property(has.chapters))] + -8 + 917 + + -200 + 5 + 2320 + 6 + script.plex/white-square.png + A0000000 + String.IsEmpty(Window.Property(has.chapters)) + + + + 0 + -175 + 1928 + 200 + script.plex/white-square.png + A0000000 + !String.IsEmpty(Window.Property(has.chapters)) + + + 40 + -162 + auto + 20 + font10 + left + center + CC606060 + + !String.IsEmpty(Window.Property(has.chapters)) + !Control.HasFocus(501) + + + 40 + -162 + auto + 20 + font10 + left + center + FFFFFFFF + + !String.IsEmpty(Window.Property(has.chapters)) + Control.HasFocus(501) + + + + + 0 + 0 + 1928 + 16 + 100 + SetProperty(hide.bigseek,) + 200 + horizontal + 4 + + + + 0 + 0 + 16 + 16 + script.plex/indicators/seek-selection-marker.png + FF606060 + + + + + + + !Control.HasFocus(501) + 0 + 0 + 16 + 16 + script.plex/indicators/seek-selection-marker.png + FF606060 + + + Control.HasFocus(501) + 0 + 0 + 16 + 16 + script.plex/indicators/seek-selection-marker.png + FFE5A00D + + + + + + + + 40 + 0 + 178 + 100 + script.plex/thumb_fallbacks/movie16x9.png + scale + CC606060 + !Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + DDAAAAAA + !Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + script.plex/thumb_fallbacks/movie16x9.png + scale + FFAAAAAA + Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + FFAAAAAA + Control.HasFocus(501) + + + 40 + 120 + auto + 10 + font10 + center + center + CC606060 + + !Control.HasFocus(501) + + + 40 + 120 + auto + 10 + font10 + center + center + FFAAAAAA + + Control.HasFocus(501) + + + + + + + + + 40 + 0 + 178 + 100 + script.plex/thumb_fallbacks/movie16x9.png + scale + CC909090 + !Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + FFAAAAAA + !Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + script.plex/thumb_fallbacks/movie16x9.png + scale + + Control.HasFocus(501) + + + 40 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + + Control.HasFocus(501) + + + 40 + 120 + auto + 10 + font10 + center + center + FFAAAAAA + + !Control.HasFocus(501) + + + 40 + 120 + auto + 10 + font10 + center + center + + + Control.HasFocus(501) + + + + + + + + Control.HasFocus(100) | Control.HasFocus(501) | !String.IsEmpty(Window.Property(button.seek)) + 0 + 896 + + -50 + 0 + + Visible + 0 + 0 + 101 + 39 + script.plex/indicators/player-selection-time_box.png + D0000000 + + + 0 + 0 + 101 + 40 + font10 + center + center + FFFFFFFF + + + + + Visible + -6 + 39 + 15 + 7 + script.plex/indicators/player-selection-time_arrow.png + D0000000 + + + + + + 30 + 797 + 1670 + 143 + right + horizontal + + [!String.IsEmpty(Window.Property(show.markerSkip)) + String.IsEmpty(Window.Property(show.markerSkip_OSDOnly))] | [!String.IsEmpty(Window.Property(show.markerSkip_OSDOnly)) + !String.IsEmpty(Window.Property(show.OSD))] + Focus + UnFocus + + + + auto + 143 + center + 0 + 0 + script.plex/buttons/blank-focus.png + script.plex/buttons/blank.png + 70 + FF000000 + FF000000 + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-settings.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-settings.xml new file mode 100644 index 000000000..3e2cdece4 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-settings.xml @@ -0,0 +1,684 @@ + + + 300 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + + Conditional + 0 + 0 + + + Conditional + + 843 + 135 + 1077 + 945 + script.plex/white-square.png + 32111111 + + + 0 + 810 + 1920 + 270 + script.plex/white-square.png + 32000000 + + + + 843 + 810 + 1077 + 270 + script.plex/white-square.png + 32111111 + + + Conditional + 1920 + 135 + 879 + 945 + script.plex/white-square.png + 66111111 + + + + + Control.HasFocus(100) | Control.HasFocus(125) + 903 + 870 + 1017 + 170 + font10 + FFFFFFFF + left + 200 + + + + + + 248 + 168 + 75 + + 0 + 0 + 590 + 666 + 201 + 100 + 200 + vertical + + + + 88 + 40 + + 0 + 0 + 414 + 75 + font12 + left + center + FFFFFFFF + + + + 0 + 75 + 414 + 2 + script.plex/white-square.png + 661F1F1F + + + + + + ControlGroup(50).HasFocus(0) + + 0 + 0 + 590 + 155 + script.plex/drop-shadow.png + + + Control.HasFocus(75) + 40 + 40 + 510 + 75 + script.plex/white-square-rounded.png + + + !Control.HasFocus(75) + 40 + 40 + 510 + 75 + script.plex/white-square-rounded.png + + + !Control.HasFocus(75) + 512 + 66 + 16 + 23 + script.plex/settings/expanded.png + FF000000 + + + 88 + 40 + 414 + 75 + font12 + left + center + FF000000 + + + + + !ControlGroup(50).HasFocus(0) + 88 + 40 + 414 + 75 + font12 + left + center + FFFFFFFF + + + + + + + Integer.IsGreater(Container(100).NumItems,0) + String.IsEmpty(Window.Property(section.about)) + 604 + 0 + 776 + 666 + 75 + 150 + 200 + vertical + 101 + + + + 88 + 40 + + 0 + 0 + 500 + 75 + font12 + left + center + FFFFFFFF + + + + 200 + 0 + 400 + true + 25 + 75 + font12 + right + center + FFFFFFFF + + + + 0 + 75 + 600 + 2 + script.plex/white-square.png + 661F1F1F + + + + 643 + 55 + + !String.IsEmpty(ListItem.Property(checkbox)) + 0 + 0 + 45 + 45 + script.plex/settings/checkbox.png + + + !String.IsEmpty(ListItem.Property(checkbox.checked)) + 0 + 0 + 45 + 45 + script.plex/settings/checkmark.png + + + + + + Control.HasFocus(100) | Control.HasFocus(125) + + 0 + 0 + 776 + 155 + script.plex/drop-shadow.png + + + !Control.HasFocus(125) + 40 + 40 + 696 + 75 + script.plex/white-square-rounded.png + + + Control.HasFocus(125) + 40 + 40 + 696 + 75 + script.plex/white-square-rounded.png + + + Control.HasFocus(125) + 698 + 66 + 16 + 23 + script.plex/settings/expanded.png + FF000000 + + + 88 + 40 + + 0 + 0 + 500 + 75 + font12 + left + center + FF000000 + + + + 200 + 0 + 400 + 75 + font12 + right + center + FF000000 + + + + + + !Control.HasFocus(100) + !Control.HasFocus(125) + 88 + 40 + + 0 + 0 + 400 + 75 + font12 + left + center + FFFFFFFF + + + + 200 + 0 + 400 + 75 + font12 + right + center + FFFFFFFF + + + + 0 + 75 + 600 + 2 + script.plex/white-square.png + 661F1F1F + + + + 643 + 55 + + !String.IsEmpty(ListItem.Property(checkbox)) + 0 + 0 + 45 + 45 + script.plex/settings/checkbox.png + + + !String.IsEmpty(ListItem.Property(checkbox.checked)) + 0 + 0 + 45 + 45 + script.plex/settings/checkmark.png + + + + + + + Control.IsVisible(100) + !Control.IsVisible(125) + 1388 + 40 + 9 + 525 + true + script.plex/white-square-rounded-4r.png + script.plex/white-square-rounded-4r.png + script.plex/white-square-rounded-4r.png + - + - + false + vertical + false + 151 + + + + Integer.IsGreater(Container(100).NumItems,0) + String.IsEmpty(Window.Property(section.about)) + 10 + 10 + 125 + 100 + font12 + FF000000 + - + - + + SetFocus(100) + + + + Control.HasFocus(125) + Integer.IsGreater(Container(100).NumItems,0) + Integer.IsGreater(Container(100).NumItems,0) + String.IsEmpty(Window.Property(section.about)) + + 1383 + 0 + 845 + 566 + 100 + 200 + vertical + 126 + + + + 88 + 40 + + 0 + 0 + 594 + 75 + font12 + left + center + FFFFFFFF + + + + 0 + 75 + 594 + 2 + script.plex/white-square.png + 661F1F1F + + + + 637 + 55 + + !String.IsEmpty(ListItem.Property(checkbox.checked)) + 0 + 0 + 45 + 45 + script.plex/settings/checkmark.png + + + + + + 0 + 0 + 610 + 155 + script.plex/drop-shadow.png + + + 40 + 40 + 690 + 75 + script.plex/white-square-rounded.png + + + 88 + 40 + + 0 + 0 + 594 + 75 + font12 + left + center + FF000000 + + + + + 637 + 55 + + !String.IsEmpty(ListItem.Property(checkbox.checked)) + 0 + 0 + 45 + 45 + script.plex/settings/checkmark.png + + + + + + + Control.IsVisible(125) + 2161 + 40 + 9 + 525 + true + script.plex/white-square-rounded-4r.png + script.plex/white-square-rounded-4r.png + script.plex/white-square-rounded-4r.png + - + - + false + vertical + false + 151 + + + + + + + + 201 + 0 + 0 + 1920 + 135 + + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font12 + left + center + FFFFFFFF + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 201 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-settings_select_dialog.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-settings_select_dialog.xml new file mode 100644 index 000000000..f619fa164 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-settings_select_dialog.xml @@ -0,0 +1,94 @@ + + + + 1 + 0 + 0 + + + + + 660 + 145 + + -40 + -40 + 680 + 870 + script.plex/drop-shadow.png + + + 0 + 0 + 600 + 80 + script.plex/white-square-top-rounded.png + F21F1F1F + + + 0 + 80 + 600 + 710 + script.plex/white-square-top-rounded.png + F2606060 + + + 0 + 0 + 600 + 80 + font12 + center + center + FFFFFFFF + + + + 0 + 80 + 600 + 700 + noop + noop + 200 + vertical + + + + 20 + 0 + 560 + 100 + font12 + left + center + FFFFFFFF + + + + + + 0 + 0 + 600 + 100 + script.plex/white-square.png + + + 20 + 0 + 560 + 100 + font12 + left + center + FF000000 + + + + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-signin_background.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-signin_background.xml new file mode 100644 index 000000000..4747d5473 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-signin_background.xml @@ -0,0 +1,21 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + + 0 + 0 + 1920 + 1080 + script.plex/sign_in/back.jpg + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-signin_blank.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-signin_blank.xml new file mode 100644 index 000000000..91e3fae5e --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-signin_blank.xml @@ -0,0 +1,49 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + + 0 + 0 + 1920 + 1080 + script.plex/sign_in/back.jpg + + + + 210 + 375 + 1502 + 530 + font13 + FFFFFFFF + left + + + + + 0 + 0 + 1920 + 1080 + font20 + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-slideshow.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-slideshow.xml new file mode 100644 index 000000000..869b7255b --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-slideshow.xml @@ -0,0 +1,63 @@ + + 6 + + + + 0 + 0 + 1920 + 1080 + + keep + 1000 + $INFO[Window.Property(thumb)] + + + 20 + 20 + 100 + 1880 + + String.IsEqual(Window.Property(align),0) + 0 + 50 + font45 + left + FFFFFFFF + FF000000 + + + + String.IsEqual(Window.Property(align),0) + 50 + 20 + font13 + left + FFFFFFFF + FF000000 + + + + String.IsEqual(Window.Property(align),1) + 0 + 50 + font45 + right + FFFFFFFF + FF000000 + + + + String.IsEqual(Window.Property(align),1) + 50 + 20 + font13 + right + FFFFFFFF + FF000000 + + + + + + \ No newline at end of file diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-squares.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-squares.xml new file mode 100644 index 000000000..eac7b3e1d --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-squares.xml @@ -0,0 +1,769 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + Conditional + Conditional + 0 + 135 + 101 + + + VisibleChange + !Integer.IsGreater(Container(101).ListItem.Property(index),5) + String.IsEmpty(Window.Property(no.content)) + String.IsEmpty(Window.Property(no.content.filtered)) + !String.IsEmpty(Window.Property(initialized)) + 301 + 30 + -25 + 1000 + 145 + 200 + 101 + -20 + horizontal + 200 + true + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + + + + + !String.IsEqual(Window(10000).Property(script.plex.item.type),collection) | String.IsEqual(Window.Property(media),collection) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/shuffle-focus.png + script.plex/buttons/shuffle.png + + + + + String.IsEmpty(Window.Property(no.options)) | Player.HasAudio + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/more-focus.png + script.plex/buttons/more.png + + + + + String.IsEmpty(Window.Property(hide.filteroptions)) + Focus + UnFocus + 0 + 0 + 126 + 100 + font12 + script.plex/buttons/chapters-focus.png + script.plex/buttons/chapters.png + + + + + + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 101 + 0 + 0 + 1920 + 1080 + + + 0 + 0 + 1800 + 1280 + 300 + 151 + 200 + vertical + 2 + 152 + + + + 55 + 97 + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(is.folder)) + 0 + 244 + 244 + 40 + script.plex/white-square.png + 80000000 + + + false + 0 + 260 + 244 + 40 + font10 + center + center + FFFFFFFF + + + + + + + + + + 55 + 97 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(101) + + String.IsEmpty(ListItem.Property(is.folder)) + -40 + -40 + 334 + 334 + script.plex/drop-shadow.png + + + !String.IsEmpty(ListItem.Property(is.folder)) + -40 + -40 + 334 + 374 + script.plex/drop-shadow.png + + + + 5 + 5 + + 0 + 0 + 244 + 244 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(is.folder)) + 0 + 244 + 244 + 40 + script.plex/white-square.png + 80000000 + + + Control.HasFocus(101) + 0 + 260 + 244 + 40 + font10 + center + center + FFFFFFFF + + + + + Control.HasFocus(101) + + String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 254 + 254 + script.plex/home/selected.png + + + !String.IsEmpty(ListItem.Property(is.folder)) + 0 + 0 + 254 + 294 + script.plex/home/selected.png + + + + + + + + + + + String.IsEqual(Window(10000).Property(script.plex.sort),titleSort) + Integer.IsGreater(Container(101).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 151 + 1780 + 150 + 20 + 920 + + 0 + 0 + 34 + 1050 + 100 + 152 + 200 + vertical + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + + + + + 0 + 0 + + 0 + 0 + + !String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(letter)) + 0 + 0 + 34 + 32 + font10 + center + center + 99FFFFFF + + + + String.IsEqual(Window(10000).Property(script.plex.key), ListItem.Property(key)) + 0 + 0 + 34 + 32 + font10 + center + center + FFE5A00D + + + + + + Control.HasFocus(151) + 0 + 0 + + Control.HasFocus(151) + 0 + 0 + 34 + 34 + FFE5A00D + script.plex/white-outline-rounded.png + + + + + + + + + + 1860 + 150 + 12 + 910 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + + + Conditional + 201 + 0 + 0 + 1920 + 135 + !String.IsEmpty(Window.Property(initialized)) + + VisibleChange + ControlGroup(200).HasFocus(0) + Integer.IsGreater(Container(101).ListItem.Property(index),5) + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font12 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 211 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + String.IsEmpty(Window.Property(hide.filteroptions)) + 340 + 35 + 1000 + 65 + right + 30 + horizontal + 204 + 210 + 50 + + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + !String.IsEqual(Window.Property(media),artist) + false + auto + 65 + font12 + FFFFFFFF + FFFFFFFF + FFFFFFFF + center + center + - + - + 20 + 0 + + + + String.IsEqual(Window.Property(media),artist) + auto + 65 + font12 + FFFFFFFF + FF000000 + FFFFFFFF + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + auto + 65 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square-rounded.png + - + 20 + 0 + + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(no.content)) + 0 + 465 + + false + 60 + 0 + 1800 + 35 + font13 + center + FFFFFFFF + + + + false + 60 + 60 + 1800 + 35 + font13 + center + FFCCCCCC + + + + + + !String.IsEmpty(Window.Property(no.content.filtered)) + 0 + 465 + + false + 60 + 0 + 1800 + 35 + font13 + center + FFFFFFFF + + + + false + 60 + 60 + 1800 + 35 + font13 + center + FFCCCCCC + + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-track_context.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-track_context.xml new file mode 100644 index 000000000..1597a7053 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-track_context.xml @@ -0,0 +1,67 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 236 + 131 + 638 + 638 + $INFO[Window.Property(track.thumb)] + + + + 236 + 131 + 1502 + 530 + font13 + FFFFFFFF + left + + + + + VisibleChange + 301 + 965 + 131 + 750 + 638 + 30 + vertical + 200 + + 30 + 30 + 1920 + 1080 + font20 + FFFFFFFF + FFFFFFFF + center + center + - + - + 0 + 0 + + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-user_select.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-user_select.xml new file mode 100644 index 000000000..143893c08 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-user_select.xml @@ -0,0 +1,901 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour_opaque)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + + + Conditional + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 441 + 780 + + + 0 + 0 + 225 + 225 + $INFO[Player.Art(thumb)] + + + + 255 + 0 + + 0 + 0 + 783 + 40 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 0 + 40 + 783 + 40 + font10 + left + center + FFFFFFFF + MusicPlayer.Album + + + 0 + 80 + 783 + 40 + font10 + left + center + FFFFFFFF + + + + + + 406 + + 255 + 134 + 783 + 124 + center + 101 + -40 + horizontal + 200 + true + + + MusicPlayer.HasPrevious + Focus + UnFocus + + 30 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + PlayerControl(Previous) + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/pause-focus.png + script.plex/buttons/pause.png + Player.Paused | Player.Forwarding | Player.Rewinding + script.plex/buttons/play-focus.png + script.plex/buttons/play.png + PlayerControl(Play) + + + + MusicPlayer.HasNext + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/next-focus.png + script.plex/buttons/next.png + PlayerControl(Next) + + + + Focus + UnFocus + + 0 + 0 + 125 + 101 + font12 + script.plex/buttons/stop-focus.png + script.plex/buttons/stop.png + PlayerControl(Stop) + + + + + + 255 + 177 + 783 + 40 + font10 + left + center + FFFFFFFF + MusicPlayer.Time + + + 1038 + 177 + 783 + 40 + font10 + right + center + FFFFFFFF + MusicPlayer.TimeRemaining + + + + + Progressbar + 255 + 222 + 783 + 3 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + + ControlGroup(400).HasFocus(0) + 770 + 275 + 380 + 695 + script.plex/drop-shadow.png + + + + 0 + 315 + 101 + + + -180 + -40 + 2100 + 455 + 200 + 500 + 600 + 400 + horizontal + 3 + + + + + + 0 + 40 + 300 + 300 + + 0 + 0 + $INFO[ListItem.Property(back.image)] + FFA0A0A0 + + + + 45 + 45 + 210 + 210 + script.plex/user_select/avatar-background.png + E0C0C0C0 + + + 54 + 54 + 192 + 192 + $INFO[ListItem.Thumb] + + + String.IsEmpty(ListItem.Thumb) + 54 + 54 + 192 + 192 + WeatherTemp + center + center + FFFFFFFF + + + + + !String.IsEmpty(ListItem.Property(protected)) + 15 + 231 + + 0 + 0 + 54 + 54 + script.plex/user_select/protected-back.png + A0000000 + + + 0 + 0 + 54 + 54 + script.plex/user_select/protected-icon.png + + + + + !String.IsEmpty(ListItem.Property(admin)) + 231 + 231 + + 0 + 0 + 54 + 54 + script.plex/user_select/admin-back.png + A0000000 + + + 0 + 0 + 54 + 54 + script.plex/user_select/admin-icon.png + + + + + + + + + + + 0 + 40 + + Control.HasFocus(101) + -40 + -40 + 380 + 455 + script.plex/drop-shadow.png + + + Control.HasFocus(101) | ControlGroup(400).HasFocus(0) + 0 + 0 + 300 + 300 + $INFO[ListItem.Property(back.image)] + FFA0A0A0 + + + !Control.HasFocus(101) + !ControlGroup(400).HasFocus(0) + 0 + 0 + 300 + 300 + $INFO[ListItem.Property(back.image)] + FFA0A0A0 + + + Control.HasFocus(101) + + 0 + 300 + 300 + 75 + script.plex/user_select/item-background-bottom.png + FF000000 + + + 0 + 300 + 300 + 75 + $INFO[ListItem.Property(back.image)] + stretch + 40FFFFFF + + + + + !Control.HasFocus(101) + !ControlGroup(400).HasFocus(0) + 45 + 45 + 210 + 210 + script.plex/user_select/avatar-background.png + E0C0C0C0 + + + ControlGroup(400).HasFocus(0) + 45 + 45 + 210 + 210 + script.plex/user_select/avatar-background.png + FFCC7B19 + + + Control.HasFocus(101) + 45 + 45 + 210 + 210 + script.plex/user_select/avatar-background.png + FFE5A00D + + + + 54 + 54 + 192 + 192 + $INFO[ListItem.Thumb] + + + String.IsEmpty(ListItem.Thumb) + 54 + 54 + 192 + 192 + WeatherTemp + center + center + FFFFFFFF + + + + + !String.IsEmpty(ListItem.Property(protected)) + 15 + 231 + + 0 + 0 + 54 + 54 + script.plex/user_select/protected-back.png + A0000000 + + + 0 + 0 + 54 + 54 + script.plex/user_select/protected-icon.png + + + + + !String.IsEmpty(ListItem.Property(admin)) + 231 + 231 + + 0 + 0 + 54 + 54 + script.plex/user_select/admin-back.png + A0000000 + + + 0 + 0 + 54 + 54 + script.plex/user_select/admin-icon.png + + + + + Control.HasFocus(101) + 10 + 300 + 280 + 75 + font13 + center + center + FFCC7B19 + + + + + + + + + 205 + !String.IsEmpty(Container(101).ListItem.Property(protected)) + ControlGroup(400).HasFocus(0) + !String.IsEmpty(Window.Property(initialized)) + 810 + 375 + + 0 + 0 + 300 + 239 + 400 + 400 + 400 + 400 + - + - + + + + 0 + -75 + 300 + 75 + script.plex/white-square.png + FF000000 + + + 0 + -75 + 300 + 75 + $INFO[Container(101).ListItem.Property(back.image)] + 40FFFFFF + + + String.IsEmpty(Container(101).ListItem.Property(editing.pin)) + 0 + -75 + 300 + 75 + font13 + center + center + FFCC7B19 + + + + !String.IsEmpty(Container(101).ListItem.Property(editing.pin)) + 0 + -75 + 300 + 75 + font13 + center + center + FFCC7B19 + + + + 0 + 0 + 300 + 239 + script.plex/white-square.png + FF000000 + + + 205 + + 205 + + 0 + 0 + 73 + 58 + 202 + 101 + 204 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + 75 + 0 + 73 + 58 + 203 + 201 + 101 + 205 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + 150 + 0 + 73 + 58 + 211 + 202 + 101 + 206 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + 0 + 60 + 73 + 58 + 205 + 207 + 201 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + 75 + 60 + 73 + 58 + 206 + 204 + 208 + 202 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + 150 + 60 + 73 + 58 + 211 + 205 + 209 + 203 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + 0 + 120 + 73 + 58 + 208 + 210 + 204 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + 75 + 120 + 73 + 58 + 209 + 207 + 210 + 205 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + 150 + 120 + 73 + 58 + 211 + 208 + 210 + 206 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + + 0 + 180 + 223 + 59 + 211 + 200 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/white-square.png + script.plex/white-square.png + 0 + 0 + + + + + 225 + 0 + 75 + 239 + 300 + 101 + font12 + FFFFFFFF + FF000000 + center + center + script.plex/user_select/backspace.png + script.plex/user_select/backspace_nf.png + 0 + 0 + + + + + + + + 201 + 0 + 0 + 1920 + 135 + + 0 + 0 + 1920 + 135 + script.plex/white-square.png + 19000000 + + + + 60 + 34.5 + 1000 + 66 + left + 60 + horizontal + 101 + true + + 0 + 0 + 124 + 66 + + 0 + 0 + 124 + 66 + 101 + 101 + right + center + script.plex/white-square-rounded.png + - + + + + !String.IsEmpty(Window.Property(dropdown)) + 0 + 0 + 124 + 66 + script.plex/white-square-rounded.png + + + 27 + 13 + + !Control.HasFocus(500) + String.IsEmpty(Window.Property(dropdown)) + 0 + 0 + + 0 + 0 + 40 + 40 + script.plex/buttons/power.png + + + 55 + 13.5 + 15 + 13 + script.plex/indicators/dropdown-triangle.png + + + + Control.HasFocus(500) | !String.IsEmpty(Window.Property(dropdown)) + 0 + 0 + + 0 + 0 + 40 + 40 + script.plex/buttons/power.png + + + 55 + 13.5 + 15 + 13 + script.plex/indicators/dropdown-triangle.png + + + + + + -27 + 0 + auto + 66 + font12 + left + center + FFFFFFFF + + + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(busy)) + Visible + + 840 + 465 + 240 + 150 + script.plex/busy-back.png + A0FFFFFF + + + 915 + 521 + 90 + 38 + script.plex/busy.gif + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-video_current_playlist.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-video_current_playlist.xml new file mode 100644 index 000000000..796140288 --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-video_current_playlist.xml @@ -0,0 +1,480 @@ + + + 100 + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + + + 101 + 750 + 140 + 1170 + 800 + + 0 + 0 + 1170 + 800 + script.plex/white-square.png + B3111111 + + + 0 + 0 + 1170 + 800 + 200 + 152 + 300 + 200 + vertical + 4 + 152 + + + + 120 + 24 + + String.IsEmpty(ListItem.Property(playing)) + -10 + 0 + 60 + 100 + font10 + center + center + D8FFFFFF + + + + !String.IsEmpty(ListItem.Property(playing)) + 2 + 32.5 + 35 + 35 + script.plex/indicators/playing-circle.png + FFE5A00D + + + String.IsEmpty(ListItem.Property(video)) + + 63 + 11 + 74 + 74 + $INFO[ListItem.Thumb] + scale + + + 168 + 0 + + 0 + 15 + 692 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 692 + 30 + font10 + left + center + B8FFFFFF + + + + + + !String.IsEmpty(ListItem.Property(video)) + + 63 + 11 + 132 + 74 + $INFO[ListItem.Thumb] + scale + + + String.IsEmpty(ListItem.Property(watched)) + 895 + -1 + 35 + 35 + script.plex/indicators/unwatched.png + + + 226 + 0 + + 0 + 15 + 584 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 584 + 30 + font10 + left + center + B8FFFFFF + + + + + + 730 + 0 + 200 + 100 + font10 + right + center + D8FFFFFF + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 98 + 930 + 2 + script.plex/white-square.png + 40000000 + + + + + + + + + !Control.HasFocus(101) + 120 + 24 + + String.IsEmpty(ListItem.Property(playing)) + -10 + 0 + 60 + 100 + font10 + center + center + D8FFFFFF + + + + !String.IsEmpty(ListItem.Property(playing)) + 0 + 32.5 + 35 + 35 + script.plex/indicators/playing-circle.png + FFE5A00D + + + String.IsEmpty(ListItem.Property(video)) + + 63 + 11 + 74 + 74 + $INFO[ListItem.Thumb] + scale + + + 168 + 0 + + 0 + 15 + 692 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 692 + 30 + font10 + left + center + B8FFFFFF + + + + + + !String.IsEmpty(ListItem.Property(video)) + + 63 + 11 + 132 + 74 + $INFO[ListItem.Thumb] + scale + + + String.IsEmpty(ListItem.Property(watched)) + 895 + -1 + 35 + 35 + script.plex/indicators/unwatched.png + + + 226 + 0 + + 0 + 15 + 584 + 30 + font10 + left + center + FFFFFFFF + + + + 0 + 50 + 584 + 30 + font10 + left + center + B8FFFFFF + + + + + + 756 + 0 + 200 + 100 + font10 + right + center + D8FFFFFF + + + + String.IsEmpty(ListItem.Property(is.footer)) + 0 + 97 + 930 + 2 + script.plex/white-square.png + 40000000 + + + + + Control.HasFocus(101) + 63 + 21 + + -40 + -40 + 1124 + 180 + script.plex/square-rounded-shadow.png + + + 0 + 0 + 1044 + 100 + script.plex/white-square-rounded.png + FFE5A00D + + + + + String.IsEmpty(ListItem.Property(playing)) + 24 + 0 + 60 + 100 + font12 + center + center + B8000000 + + + + !String.IsEmpty(ListItem.Property(playing)) + 36 + 32.5 + 35 + 35 + script.plex/indicators/playing-circle.png + FF000000 + + + String.IsEmpty(ListItem.Property(video)) + + String.IsEmpty(ListItem.Property(video)) + 103 + 0 + 100 + 100 + $INFO[ListItem.Thumb] + scale + + + 235 + 0 + + 0 + 16 + 638 + 30 + font12 + left + center + DF000000 + + + + 0 + 51 + 638 + 30 + font10 + left + center + 98000000 + + + + + + !String.IsEmpty(ListItem.Property(video)) + + 103 + 0 + 178 + 100 + $INFO[ListItem.Thumb] + scale + + + String.IsEmpty(ListItem.Property(watched)) + 951 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + 313 + 0 + + 0 + 16 + 510 + 30 + font12 + left + center + DF000000 + + + + 0 + 51 + 510 + 30 + font10 + left + center + 98000000 + + + + + + 802 + 0 + 200 + 100 + font12 + right + center + B8000000 + + + + + + + + + + + 1128 + 33 + 10 + 734 + 101 + true + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + script.plex/white-square-rounded.png + - + - + false + vertical + false + 151 + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-video_player.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-video_player.xml new file mode 100644 index 000000000..12d2f4f3b --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-video_player.xml @@ -0,0 +1,1343 @@ + + + + 1 + 0 + 0 + + $INFO[Window.Property(background_colour)] + + + String.IsEmpty(Window.Property(use_solid_background)) + + !String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background_static)] + + + String.IsEmpty(Window.Property(use_bg_fallback)) + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + !String.IsEmpty(Window.Property(post.play)) + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(post.play.background)] + + + + + Conditional + + + + + + + + + + 0 + 135 + 102 + + + 60 + 0 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + + false + 60 + 57 + 462 + 40 + font12 + left + center + A0FFFFFF + + + + + 102 + + 0 + 0 + 1920 + 580 + + 60 + 131 + + Conditional + 0 + 0 + + Control.HasFocus(101) + -45 + -45 + 552 + 349 + script.plex/drop-shadow.png + + + 0 + 0 + + 0 + 0 + 462 + 259 + $INFO[Window.Property(thumb.fallback)] + scale + + + 0 + 0 + 462 + 259 + $INFO[Window.Property(prev.thumb)] + scale + + + 193 + 91.5 + + 0 + 0 + 76 + 76 + script.plex/indicators/circle-152.png + + + 15 + 15 + 46 + 46 + script.plex/indicators/replay.png + + + + false + 0 + 269 + 462 + 35 + font10 + center + FFFFFFFF + + + + false + 0 + 301 + 462 + 35 + font10 + center + FFFFFFFF + + + + + -5 + -5 + 472 + 269 + 200 + 400 + 102 + script.plex/home/selected.png + - + + + + + + !String.IsEmpty(Window.Property(has.next)) + + false + 572 + 57 + 462 + 40 + font12 + left + center + FFFFFFFF + + + + 582 + 131 + + Conditional + 0 + 0 + + Control.HasFocus(102) + -45 + -45 + 627 + 393 + script.plex/drop-shadow.png + + + 0 + 0 + + 0 + 0 + 537 + 303 + $INFO[Window.Property(thumb.fallback)] + scale + + + 0 + 0 + 537 + 303 + $INFO[Window.Property(next.thumb)] + scale + + + !String.IsEmpty(Window.Property(countdown)) + Integer.IsGreaterOrEqual(Window.Property(countdown), 0) + 192.5 + 75.5 + + 0 + 0 + 152 + 152 + script.plex/indicators/circle-152.png + + + 8 + 8 + 136 + 136 + script.plex/circle-progress/$INFO[Window.Property(countdown)].png + + + 59.5 + 57 + 33 + 38 + script.plex/indicators/pause.png + + + + false + 0 + 313 + 537 + 30 + font10 + center + FFFFFFFF + + + + false + 0 + 345 + 537 + 30 + font10 + center + FFFFFFFF + + + + + -5 + -5 + 547 + 313 + 200 + 400 + 101 + script.plex/home/selected.png + - + + + + + + + + !String.IsEmpty(Window.Property(has.next)) + + false + 1177 + 131 + 683 + 43 + font13 + left + center + FFFFFFFF + + + + false + 1177 + 189 + 683 + 32 + font12 + left + center + A0FFFFFF + + + + false + 1177 + 300 + 683 + 215 + font12 + left + FFFFFFFF + + + + + + String.IsEmpty(Window.Property(has.next)) + + false + 580 + 131 + 1280 + 43 + font13 + left + center + FFFFFFFF + + + + false + 580 + 189 + 1280 + 32 + font12 + left + center + A0FFFFFF + + + + false + 580 + 300 + 1280 + 225 + font12 + left + FFFFFFFF + + + + + + + 0 + 585 + 1920 + 1610 + + 300 + 0 + + + Integer.IsGreater(Container(400).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 360 + 1920 + + 0 + 0 + 1920 + 360 + script.plex/white-square.png + 40000000 + + + 60 + 0 + 800 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 18 + 1920 + 430 + 100 + 401 + false + false + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 158 + + 0 + 0 + 299 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 299 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 264 + 0 + 35 + 35 + script.plex/indicators/unwatched.png + + + false + 0 + 180 + 299 + 35 + font10 + center + FFFFFFFF + + + + false + 0 + 212 + 299 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 299 + 168 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 119 + 34 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 119 + 34 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 85.5 + 20 + 128 + 128 + script.plex/home/busy.gif + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(400) + -40 + -40 + 389 + 258 + script.plex/drop-shadow.png + + + 5 + 5 + + 0 + 0 + 299 + 168 + $INFO[ListItem.Property(thumb.fallback)] + scale + + + 0 + 0 + 299 + 168 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 158 + + 0 + 0 + 299 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 299 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 264 + 0 + 35 + 35 + script.plex/indicators/unwatched.png + + + false + 0 + 180 + 299 + 35 + font10 + center + FFFFFFFF + + + + false + 0 + 212 + 299 + 35 + font10 + center + FFFFFFFF + + + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 299 + 168 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 119 + 34 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 119 + 34 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 85.5 + 20 + 128 + 128 + script.plex/home/busy.gif + + + + + Control.HasFocus(400) + 0 + 0 + 309 + 178 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(401).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 401 + 1920 + 520 + + !String.IsEmpty(Window.Property(divider.401)) + 60 + 0 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 0 + 1000 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 16 + 1920 + 520 + 400 + 403 + false + false + 200 + horizontal + 4 + + + + 55 + 72 + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 369 + 244 + 38 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 72 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(401) + -40 + -40 + 324 + 441 + script.plex/drop-shadow.png + + + 5 + 5 + + !String.IsEmpty(ListItem.Property(is.boundary)) + + 0 + 0 + 244 + 361 + script.plex/white-square.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(right.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white.png + + + String.IsEmpty(ListItem.Property(is.updating)) + !String.IsEmpty(ListItem.Property(left.boundary)) + 91.5 + 130.5 + 61 + 100 + script.plex/indicators/chevron-white-l.png + + + !String.IsEmpty(ListItem.Property(is.updating)) + 58 + 116.5 + 128 + 128 + script.plex/home/busy.gif + + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Property(thumb.fallback)] + + + 0 + 0 + 244 + 361 + $INFO[ListItem.Thumb] + scale + + + !String.IsEmpty(ListItem.Property(progress)) + 0 + 351 + + 0 + 0 + 244 + 10 + script.plex/white-square.png + C0000000 + + + 0 + 1 + 244 + 8 + $INFO[ListItem.Property(progress)] + FFCC7B19 + + + + !String.IsEmpty(ListItem.Property(unwatched)) + 196 + 0 + 48 + 48 + script.plex/indicators/unwatched.png + + + !String.IsEmpty(ListItem.Property(unwatched.count)) + + 193 + 0 + 51 + 39 + script.plex/white-square.png + FF000000 + + + 194 + 0 + 50 + 38 + script.plex/white-square.png + FFCC7B19 + + + 194 + 0 + 50 + 38 + font12 + center + center + FF000000 + + + + + false + 0 + 369 + 244 + 38 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(401) + 0 + 0 + 254 + 371 + script.plex/home/selected.png + + + + + + + + + Integer.IsGreater(Container(403).NumItems,0) + String.IsEmpty(Window.Property(drawing)) + 403 + 1920 + 410 + + !String.IsEmpty(Window.Property(divider.403)) + 60 + 20 + 1800 + 2 + script.plex/white-square.png + A0000000 + + + 60 + 20 + 1000 + 80 + font12 + left + center + FFFFFFFF + + + + 0 + 36 + 1920 + 410 + 401 + 404 + 200 + horizontal + 4 + + + + 55 + 61 + + 5 + 5 + + 0 + 0 + 244 + 244 + script.plex/thumb_fallbacks/role.png + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + 0 + 253 + 244 + 90 + font10 + center + FFFFFFFF + + + + + + + + + + 55 + 61 + + Focus + UnFocus + 0 + 0 + + Control.HasFocus(403) + -40 + -40 + 334 + 334 + script.plex/buttons/role-shadow.png + + + 5 + 5 + + 0 + 0 + 244 + 244 + script.plex/thumb_fallbacks/role.png + + + 0 + 0 + 244 + 244 + $INFO[ListItem.Thumb] + scale + + + 0 + 253 + 244 + 90 + font10 + center + FFFFFFFF + + + + + Control.HasFocus(403) + 0 + 0 + 254 + 254 + script.plex/buttons/role-selected.png + + + + + + + + + + + Conditional + 201 + 0 + 0 + 1920 + 135 + + VisibleChange + ControlGroup(200).HasFocus(0) + !String.IsEmpty(Window.Property(on.extras)) + 0 + 0 + 1920 + 135 + script.plex/white-square.png + C0000000 + + + 60 + 47.5 + 1000 + 40 + left + 60 + horizontal + 50 + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 202 + 50 + font12 + FF000000 + script.plex/buttons/home-focus.png + script.plex/buttons/home.png + + + + + auto + 40 + font12 + left + center + FFFFFFFF + + + + 40 + 40 + + Focus + UnFocus + 40 + 40 + 204 + 201 + 50 + font12 + FF000000 + script.plex/buttons/search-focus.png + script.plex/buttons/search.png + + + + + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + 438 + 0 + + Player.HasAudio + String.IsEmpty(Window(10000).Property(script.plex.theme_playing)) + -10 + 38 + 260 + 75 + 202 + 50 + font12 + FFFFFFFF + FF000000 + right + center + script.plex/white-square-rounded.png + - + 100 + 0 + + + + 0 + 48 + 42 + 42 + $INFO[Player.Art(thumb)] + + + + !Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FFFFFFFF + MusicPlayer.Title + + + + Control.HasFocus(204) + + 53 + 48 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Artist + + + 53 + 72 + 187 + 20 + font10 + left + center + FF000000 + MusicPlayer.Title + + + + + Progressbar + 0 + 102 + 240 + 1 + script.plex/white-square-1px.png + - + script.plex/white-square-1px.png + - + - + Player.Progress + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + script.plex/home/plex.png + + + + + !String.IsEmpty(Window.Property(search.dialog)) + + !String.IsEmpty(Window.Property(search.dialog.hasresults)) + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + $INFO[Window.Property(background)] + + + + 0 + 0 + 1920 + 1080 + script.plex/white-square.png + + + + + diff --git a/script.plexmod/resources/skins/Main/1080i/script-plex-video_settings_dialog.xml b/script.plexmod/resources/skins/Main/1080i/script-plex-video_settings_dialog.xml new file mode 100644 index 000000000..4bf41907b --- /dev/null +++ b/script.plexmod/resources/skins/Main/1080i/script-plex-video_settings_dialog.xml @@ -0,0 +1,159 @@ + + + + 1 + 0 + 0 + + + + + !String.IsEmpty(Window.Property(via.OSD)) + Hidden + 0 + 0 + + 0 + 0 + 1920 + 1080 + script.plex/player-fade.png + FF080808 + + + + !Window.IsVisible(sliderdialog) + !Window.IsVisible(osdvideosettings) + !Window.IsVisible(osdaudiosettings) + !Window.IsVisible(osdsubtitlesettings) + !Window.IsVisible(subtitlesearch) + 460 + 200 + + -40 + -40 + 1080 + 770 + script.plex/drop-shadow.png + + + 0 + 0 + 1000 + 80 + script.plex/white-square-top-rounded.png + F21F1F1F + + + 0 + 80 + 1000 + 610 + script.plex/white-square-top-rounded.png + F2606060 + + + 0 + 80 + 400 + 610 + script.plex/white-square-tl-rounded.png + 30000000 + + + 0 + 0 + 1000 + 80 + font12 + center + center + FFFFFFFF + + + + 0 + 80 + 990 + 600 + noop + noop + 200 + vertical + 101 + 101 + + + + 20 + 0 + 300 + 100 + font12 + left + center + FFFFFFFF + + + + 320 + 0 + 650 + 100 + font12 + right + center + FFFFFFFF + + + + + + 0 + 0 + 1000 + 100 + script.plex/white-square.png + + + 20 + 0 + 300 + 100 + font12 + left + center + FF000000 + + + + 320 + 0 + 650 + 100 + font12 + right + center + FF000000 + + + + + + + + 1450 + 280 + 10 + 600 + 101 + true + script.plex/white-square.png + script.plex/white-square.png + script.plex/white-square.png + - + - + false + vertical + false + 100 + + + + diff --git a/script.plexmod/resources/skins/Main/media/script.plex/busy-back.png b/script.plexmod/resources/skins/Main/media/script.plex/busy-back.png new file mode 100644 index 000000000..f193fb26b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/busy-back.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/busy-diffuse.png b/script.plexmod/resources/skins/Main/media/script.plex/busy-diffuse.png new file mode 100644 index 000000000..205b216d1 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/busy-diffuse.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/busy.gif b/script.plexmod/resources/skins/Main/media/script.plex/busy.gif new file mode 100644 index 000000000..d97a260ad Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/busy.gif differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/blank-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/blank-focus.png new file mode 100644 index 000000000..4b9fe6b3f Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/blank-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/blank.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/blank.png new file mode 100644 index 000000000..11f18ffd5 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/blank.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/chapters-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/chapters-focus.png new file mode 100644 index 000000000..efedb9dea Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/chapters-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/chapters.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/chapters.png new file mode 100644 index 000000000..ea7d83d96 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/chapters.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/home-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/home-focus.png new file mode 100644 index 000000000..baa780c30 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/home-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/home.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/home.png new file mode 100644 index 000000000..9da048140 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/home.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/info-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/info-focus.png new file mode 100644 index 000000000..407b37b58 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/info-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/info.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/info.png new file mode 100644 index 000000000..ea0b1a344 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/info.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/media-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/media-focus.png new file mode 100644 index 000000000..a89f1c340 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/media-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/media.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/media.png new file mode 100644 index 000000000..6c69c9be2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/media.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/more-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/more-focus.png new file mode 100644 index 000000000..c814cf744 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/more-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/more-vertical.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/more-vertical.png new file mode 100644 index 000000000..4faa5ce6b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/more-vertical.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/more.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/more.png new file mode 100644 index 000000000..cfd025d39 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/more.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/next-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/next-focus.png new file mode 100644 index 000000000..b9d5e0aa3 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/next-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/next.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/next.png new file mode 100644 index 000000000..6ec144c45 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/next.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/pause-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/pause-focus.png new file mode 100644 index 000000000..5d928f476 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/pause-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/pause.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/pause.png new file mode 100644 index 000000000..9f0083e82 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/pause.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/play-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/play-focus.png new file mode 100644 index 000000000..e39c5ce15 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/play-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/play.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/play.png new file mode 100644 index 000000000..23e8c116a Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/play.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/power.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/power.png new file mode 100644 index 000000000..155032cae Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/power.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/pqueue-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/pqueue-focus.png new file mode 100644 index 000000000..cd23065b8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/pqueue-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/pqueue.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/pqueue.png new file mode 100644 index 000000000..231e9350d Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/pqueue.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat-focus.png new file mode 100644 index 000000000..3f86ee802 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat-one-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat-one-focus.png new file mode 100644 index 000000000..bc0660b51 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat-one-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat-one.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat-one.png new file mode 100644 index 000000000..485ad9c36 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat-one.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat.png new file mode 100644 index 000000000..850245867 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/repeat.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/resume-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/resume-focus.png new file mode 100644 index 000000000..9c68899ec Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/resume-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/resume.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/resume.png new file mode 100644 index 000000000..452a67ef4 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/resume.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/role-selected.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/role-selected.png new file mode 100644 index 000000000..d06c6fc15 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/role-selected.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/role-shadow.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/role-shadow.png new file mode 100644 index 000000000..16805a373 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/role-shadow.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/rotate-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/rotate-focus.png new file mode 100644 index 000000000..43bbe7541 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/rotate-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/rotate.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/rotate.png new file mode 100644 index 000000000..2fb2730f7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/rotate.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/search-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/search-focus.png new file mode 100644 index 000000000..7d532ac56 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/search-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/search.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/search.png new file mode 100644 index 000000000..c89f56534 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/search.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/settings-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/settings-focus.png new file mode 100644 index 000000000..7af446da5 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/settings-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/settings.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/settings.png new file mode 100644 index 000000000..e24421bc0 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/settings.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/shuffle-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/shuffle-focus.png new file mode 100644 index 000000000..8dbadf6e3 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/shuffle-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/shuffle.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/shuffle.png new file mode 100644 index 000000000..a67dbde2b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/shuffle.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/skip-forward-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/skip-forward-focus.png new file mode 100644 index 000000000..a8533b2d6 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/skip-forward-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/skip-forward.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/skip-forward.png new file mode 100644 index 000000000..6377f753a Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/skip-forward.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/square2x2-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/square2x2-focus.png new file mode 100644 index 000000000..66f17a8c8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/square2x2-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/square2x2.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/square2x2.png new file mode 100644 index 000000000..4e2e477ea Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/square2x2.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/stop-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/stop-focus.png new file mode 100644 index 000000000..50588e55b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/stop-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/stop.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/stop.png new file mode 100644 index 000000000..1a8d4d9f7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/stop.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/subtitle-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/subtitle-focus.png new file mode 100644 index 000000000..558495060 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/subtitle-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/subtitle.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/subtitle.png new file mode 100644 index 000000000..e98064d07 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/subtitle.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/tags-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/tags-focus.png new file mode 100644 index 000000000..8eb87591d Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/tags-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/tags.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/tags.png new file mode 100644 index 000000000..4ef59db7e Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/tags.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/trailer-focus.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/trailer-focus.png new file mode 100644 index 000000000..57bbb48aa Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/trailer-focus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/buttons/trailer.png b/script.plexmod/resources/skins/Main/media/script.plex/buttons/trailer.png new file mode 100644 index 000000000..afd830e9a Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/buttons/trailer.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/0.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/0.png new file mode 100644 index 000000000..5ef79460d Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/0.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/1.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/1.png new file mode 100644 index 000000000..a4571b6ec Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/1.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/10.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/10.png new file mode 100644 index 000000000..009f5be4a Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/10.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/11.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/11.png new file mode 100644 index 000000000..5faadd7c4 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/11.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/12.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/12.png new file mode 100644 index 000000000..711c177dd Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/12.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/13.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/13.png new file mode 100644 index 000000000..d841a476c Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/13.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/14.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/14.png new file mode 100644 index 000000000..b0a2062d8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/14.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/15.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/15.png new file mode 100644 index 000000000..848639d40 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/15.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/2.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/2.png new file mode 100644 index 000000000..45c510f71 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/2.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/3.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/3.png new file mode 100644 index 000000000..76c1403f6 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/3.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/4.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/4.png new file mode 100644 index 000000000..7b126ab2e Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/4.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/5.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/5.png new file mode 100644 index 000000000..0c0212e92 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/5.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/6.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/6.png new file mode 100644 index 000000000..5b1d99739 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/6.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/7.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/7.png new file mode 100644 index 000000000..9d15832eb Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/7.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/8.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/8.png new file mode 100644 index 000000000..f221fa78d Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/8.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/9.png b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/9.png new file mode 100644 index 000000000..c6885f460 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/circle-progress/9.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/drop-shadow.png b/script.plexmod/resources/skins/Main/media/script.plex/drop-shadow.png new file mode 100644 index 000000000..ca961689b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/drop-shadow.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/gray-square.png b/script.plexmod/resources/skins/Main/media/script.plex/gray-square.png new file mode 100644 index 000000000..453a5a2f8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/gray-square.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/avatar-diffuse.png b/script.plexmod/resources/skins/Main/media/script.plex/home/avatar-diffuse.png new file mode 100644 index 000000000..f64652769 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/avatar-diffuse.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/background-fallback.png b/script.plexmod/resources/skins/Main/media/script.plex/home/background-fallback.png new file mode 100644 index 000000000..4e3c9a815 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/background-fallback.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/busy.gif b/script.plexmod/resources/skins/Main/media/script.plex/home/busy.gif new file mode 100644 index 000000000..0c0828a6c Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/busy.gif differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/check.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/check.png new file mode 100644 index 000000000..c6e2c3d22 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/check.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/error.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/error.png new file mode 100644 index 000000000..c60c20017 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/error.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-refreshing.gif b/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-refreshing.gif new file mode 100644 index 000000000..b135ff297 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-refreshing.gif differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-secure.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-secure.png new file mode 100644 index 000000000..42d85bfc7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-secure.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-unknown.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-unknown.png new file mode 100644 index 000000000..42d85bfc7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-unknown.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-unreachable.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-unreachable.png new file mode 100644 index 000000000..c1912ca77 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/focus-unreachable.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/home.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/home.png new file mode 100644 index 000000000..d4c50b90f Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/home.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/home_small.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/home_small.png new file mode 100644 index 000000000..8ce049d4b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/home_small.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/lock-24px.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/lock-24px.png new file mode 100644 index 000000000..7f08d39b4 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/lock-24px.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/lock.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/lock.png new file mode 100644 index 000000000..8242564b2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/lock.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/plex.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/plex.png new file mode 100644 index 000000000..3ed0eec57 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/plex.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/refreshing.gif b/script.plexmod/resources/skins/Main/media/script.plex/home/device/refreshing.gif new file mode 100644 index 000000000..561a16b83 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/refreshing.gif differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/secure.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/secure.png new file mode 100644 index 000000000..da0f2dab0 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/secure.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/unknown.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/unknown.png new file mode 100644 index 000000000..564c2864a Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/unknown.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/device/unreachable.png b/script.plexmod/resources/skins/Main/media/script.plex/home/device/unreachable.png new file mode 100644 index 000000000..a06aa49c3 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/device/unreachable.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/plex.png b/script.plexmod/resources/skins/Main/media/script.plex/home/plex.png new file mode 100644 index 000000000..9a94e2409 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/plex.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/selected-section.png b/script.plexmod/resources/skins/Main/media/script.plex/home/selected-section.png new file mode 100644 index 000000000..9339012e6 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/selected-section.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/selected.png b/script.plexmod/resources/skins/Main/media/script.plex/home/selected.png new file mode 100644 index 000000000..5f25aaaf7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/selected.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/type/artist.png b/script.plexmod/resources/skins/Main/media/script.plex/home/type/artist.png new file mode 100644 index 000000000..bd8e0177f Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/type/artist.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/type/channels.png b/script.plexmod/resources/skins/Main/media/script.plex/home/type/channels.png new file mode 100644 index 000000000..c03c9be48 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/type/channels.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/type/home-selected.png b/script.plexmod/resources/skins/Main/media/script.plex/home/type/home-selected.png new file mode 100644 index 000000000..023557b72 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/type/home-selected.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/type/home.png b/script.plexmod/resources/skins/Main/media/script.plex/home/type/home.png new file mode 100644 index 000000000..8b94f1bdf Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/type/home.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/type/movie.png b/script.plexmod/resources/skins/Main/media/script.plex/home/type/movie.png new file mode 100644 index 000000000..5ff5f0abe Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/type/movie.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/type/photo.png b/script.plexmod/resources/skins/Main/media/script.plex/home/type/photo.png new file mode 100644 index 000000000..4d528852d Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/type/photo.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/type/playlists.png b/script.plexmod/resources/skins/Main/media/script.plex/home/type/playlists.png new file mode 100644 index 000000000..b0576a1a6 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/type/playlists.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/home/type/show.png b/script.plexmod/resources/skins/Main/media/script.plex/home/type/show.png new file mode 100644 index 000000000..d94060b71 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/home/type/show.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/arrow-down.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/arrow-down.png new file mode 100644 index 000000000..d70a3ed90 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/arrow-down.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/arrow-up.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/arrow-up.png new file mode 100644 index 000000000..0e3b4f480 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/arrow-up.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/busy-photo.gif b/script.plexmod/resources/skins/Main/media/script.plex/indicators/busy-photo.gif new file mode 100644 index 000000000..70ffff8fa Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/busy-photo.gif differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/camera.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/camera.png new file mode 100644 index 000000000..4f3b7b4e5 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/camera.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/chevron-white-l.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/chevron-white-l.png new file mode 100644 index 000000000..45a6bcbee Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/chevron-white-l.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/chevron-white.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/chevron-white.png new file mode 100644 index 000000000..3f1d8f0f2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/chevron-white.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/circle-152.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/circle-152.png new file mode 100644 index 000000000..b1862c0f0 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/circle-152.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/circle-19.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/circle-19.png new file mode 100644 index 000000000..9d1a852dc Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/circle-19.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/cornerbox.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/cornerbox.png new file mode 100644 index 000000000..510dfb891 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/cornerbox.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/dropdown-triangle.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/dropdown-triangle.png new file mode 100644 index 000000000..ce90adcb8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/dropdown-triangle.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/info-sep.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/info-sep.png new file mode 100644 index 000000000..b55e87c5b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/info-sep.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/pause.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/pause.png new file mode 100644 index 000000000..622f04c76 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/pause.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/player-selection-time.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/player-selection-time.png new file mode 100644 index 000000000..80ff25d9b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/player-selection-time.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/player-selection-time_arrow.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/player-selection-time_arrow.png new file mode 100644 index 000000000..fe5e15a2e Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/player-selection-time_arrow.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/player-selection-time_box.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/player-selection-time_box.png new file mode 100644 index 000000000..5f2e25c92 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/player-selection-time_box.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/playing-circle.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/playing-circle.png new file mode 100644 index 000000000..e02072070 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/playing-circle.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/remove.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/remove.png new file mode 100644 index 000000000..bfc5c3514 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/remove.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/replay.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/replay.png new file mode 100644 index 000000000..ac7cbe2e0 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/replay.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/seek-selection-marker.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/seek-selection-marker.png new file mode 100644 index 000000000..d650a89b9 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/seek-selection-marker.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/spinner.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/spinner.png new file mode 100644 index 000000000..6ddb4d4ac Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/spinner.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/unwatched-rounded.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/unwatched-rounded.png new file mode 100644 index 000000000..0391998eb Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/unwatched-rounded.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/indicators/unwatched.png b/script.plexmod/resources/skins/Main/media/script.plex/indicators/unwatched.png new file mode 100644 index 000000000..106c18bae Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/indicators/unwatched.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/masks/listview-16x9-backgroundmask.png b/script.plexmod/resources/skins/Main/media/script.plex/masks/listview-16x9-backgroundmask.png new file mode 100644 index 000000000..092aa9d2e Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/masks/listview-16x9-backgroundmask.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/masks/role.png b/script.plexmod/resources/skins/Main/media/script.plex/masks/role.png new file mode 100644 index 000000000..302d405cf Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/masks/role.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/player-fade.png b/script.plexmod/resources/skins/Main/media/script.plex/player-fade.png new file mode 100644 index 000000000..c84d4b604 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/player-fade.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/0.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/0.png new file mode 100644 index 000000000..4ff39bf62 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/0.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/10.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/10.png new file mode 100644 index 000000000..e948275c9 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/10.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/100.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/100.png new file mode 100644 index 000000000..b25f1dc57 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/100.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/12.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/12.png new file mode 100644 index 000000000..3cff59469 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/12.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/14.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/14.png new file mode 100644 index 000000000..fef67aee2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/14.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/16.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/16.png new file mode 100644 index 000000000..2ae7f6909 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/16.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/18.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/18.png new file mode 100644 index 000000000..92f3b3af8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/18.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/2.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/2.png new file mode 100644 index 000000000..50c974ff7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/2.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/20.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/20.png new file mode 100644 index 000000000..c799b4c6d Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/20.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/22.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/22.png new file mode 100644 index 000000000..b79551d56 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/22.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/24.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/24.png new file mode 100644 index 000000000..7083c4623 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/24.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/26.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/26.png new file mode 100644 index 000000000..d4d13e887 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/26.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/28.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/28.png new file mode 100644 index 000000000..dfc2b164a Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/28.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/30.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/30.png new file mode 100644 index 000000000..a0c9aa4b8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/30.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/32.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/32.png new file mode 100644 index 000000000..e4224f7b8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/32.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/34.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/34.png new file mode 100644 index 000000000..08a0b78c9 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/34.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/36.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/36.png new file mode 100644 index 000000000..74889d156 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/36.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/38.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/38.png new file mode 100644 index 000000000..c653854d7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/38.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/4.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/4.png new file mode 100644 index 000000000..06aaed9b7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/4.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/40.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/40.png new file mode 100644 index 000000000..02bae0233 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/40.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/42.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/42.png new file mode 100644 index 000000000..a5259c967 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/42.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/44.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/44.png new file mode 100644 index 000000000..72a4f4c13 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/44.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/46.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/46.png new file mode 100644 index 000000000..6e7155e69 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/46.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/48.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/48.png new file mode 100644 index 000000000..42a61770e Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/48.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/50.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/50.png new file mode 100644 index 000000000..0e0934d22 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/50.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/52.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/52.png new file mode 100644 index 000000000..353fa3678 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/52.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/54.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/54.png new file mode 100644 index 000000000..66cae819e Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/54.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/56.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/56.png new file mode 100644 index 000000000..7f806034c Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/56.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/58.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/58.png new file mode 100644 index 000000000..2aad245a9 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/58.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/6.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/6.png new file mode 100644 index 000000000..56ce81719 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/6.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/60.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/60.png new file mode 100644 index 000000000..3e299ed84 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/60.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/62.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/62.png new file mode 100644 index 000000000..8adb561c0 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/62.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/64.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/64.png new file mode 100644 index 000000000..228dba450 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/64.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/66.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/66.png new file mode 100644 index 000000000..322c26e43 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/66.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/68.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/68.png new file mode 100644 index 000000000..0c853000b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/68.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/70.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/70.png new file mode 100644 index 000000000..869f9d751 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/70.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/72.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/72.png new file mode 100644 index 000000000..e9158e628 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/72.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/74.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/74.png new file mode 100644 index 000000000..f8e02419f Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/74.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/76.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/76.png new file mode 100644 index 000000000..d99989e92 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/76.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/78.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/78.png new file mode 100644 index 000000000..2333dba5c Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/78.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/8.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/8.png new file mode 100644 index 000000000..12472cb92 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/8.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/80.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/80.png new file mode 100644 index 000000000..873fdc264 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/80.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/82.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/82.png new file mode 100644 index 000000000..4f40e6ba6 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/82.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/84.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/84.png new file mode 100644 index 000000000..86a77bcee Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/84.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/86.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/86.png new file mode 100644 index 000000000..bd9404700 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/86.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/88.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/88.png new file mode 100644 index 000000000..b5c34a4d1 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/88.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/90.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/90.png new file mode 100644 index 000000000..528fc9887 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/90.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/92.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/92.png new file mode 100644 index 000000000..565e15edf Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/92.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/94.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/94.png new file mode 100644 index 000000000..5653ff0cb Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/94.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/96.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/96.png new file mode 100644 index 000000000..6bfbd5ccc Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/96.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/progress/98.png b/script.plexmod/resources/skins/Main/media/script.plex/progress/98.png new file mode 100644 index 000000000..60579118f Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/progress/98.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/ratings/imdb/image.rating.png b/script.plexmod/resources/skins/Main/media/script.plex/ratings/imdb/image.rating.png new file mode 100644 index 000000000..adce94124 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/ratings/imdb/image.rating.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/ratings/other/image.rating.png b/script.plexmod/resources/skins/Main/media/script.plex/ratings/other/image.rating.png new file mode 100644 index 000000000..a5f83e717 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/ratings/other/image.rating.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.certified.png b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.certified.png new file mode 100644 index 000000000..0607c9c11 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.certified.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.plus.png b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.plus.png new file mode 100644 index 000000000..57758c6a2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.plus.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.ripe.png b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.ripe.png new file mode 100644 index 000000000..5ad9dffb3 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.ripe.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.rotten.png b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.rotten.png new file mode 100644 index 000000000..2b7c9bcf4 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.rotten.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.spilled.png b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.spilled.png new file mode 100644 index 000000000..0f89c619e Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.spilled.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.upright.png b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.upright.png new file mode 100644 index 000000000..42597e48f Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/ratings/rottentomatoes/image.rating.upright.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/ratings/tmdb/image.rating.png b/script.plexmod/resources/skins/Main/media/script.plex/ratings/tmdb/image.rating.png new file mode 100644 index 000000000..cc94da93f Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/ratings/tmdb/image.rating.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/reviews/image.review.fresh.png b/script.plexmod/resources/skins/Main/media/script.plex/reviews/image.review.fresh.png new file mode 100644 index 000000000..a18fb601a Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/reviews/image.review.fresh.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/reviews/image.review.rotten.png b/script.plexmod/resources/skins/Main/media/script.plex/reviews/image.review.rotten.png new file mode 100644 index 000000000..39f687008 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/reviews/image.review.rotten.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/section_type/movie.png b/script.plexmod/resources/skins/Main/media/script.plex/section_type/movie.png new file mode 100644 index 000000000..57baf25d0 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/section_type/movie.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/section_type/music.png b/script.plexmod/resources/skins/Main/media/script.plex/section_type/music.png new file mode 100644 index 000000000..5ec8469f6 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/section_type/music.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/section_type/photo.png b/script.plexmod/resources/skins/Main/media/script.plex/section_type/photo.png new file mode 100644 index 000000000..814e233cd Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/section_type/photo.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/section_type/show.png b/script.plexmod/resources/skins/Main/media/script.plex/section_type/show.png new file mode 100644 index 000000000..434a46f20 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/section_type/show.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/settings/checkbox.png b/script.plexmod/resources/skins/Main/media/script.plex/settings/checkbox.png new file mode 100644 index 000000000..8fbec4ff4 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/settings/checkbox.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/settings/checkmark.png b/script.plexmod/resources/skins/Main/media/script.plex/settings/checkmark.png new file mode 100644 index 000000000..641a45902 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/settings/checkmark.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/settings/expanded.png b/script.plexmod/resources/skins/Main/media/script.plex/settings/expanded.png new file mode 100644 index 000000000..801bfa80b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/settings/expanded.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/back.jpg b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/back.jpg new file mode 100644 index 000000000..5ea94ee01 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/back.jpg differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/0.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/0.png new file mode 100644 index 000000000..e04b08334 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/0.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/1.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/1.png new file mode 100644 index 000000000..75318efd6 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/1.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/2.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/2.png new file mode 100644 index 000000000..f3a4f6406 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/2.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/3.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/3.png new file mode 100644 index 000000000..21897e381 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/3.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/4.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/4.png new file mode 100644 index 000000000..f3bb0fe77 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/4.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/5.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/5.png new file mode 100644 index 000000000..d239ea976 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/5.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/6.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/6.png new file mode 100644 index 000000000..061359615 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/6.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/7.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/7.png new file mode 100644 index 000000000..a9e573d20 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/7.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/8.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/8.png new file mode 100644 index 000000000..d4f827e6e Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/8.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/9.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/9.png new file mode 100644 index 000000000..1228b3cb1 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/9.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/A.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/A.png new file mode 100644 index 000000000..f674ffbb1 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/A.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/B.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/B.png new file mode 100644 index 000000000..c31c541e4 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/B.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/C.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/C.png new file mode 100644 index 000000000..551976f50 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/C.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/D.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/D.png new file mode 100644 index 000000000..fcfa04f79 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/D.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/E.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/E.png new file mode 100644 index 000000000..565e1ec3b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/E.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/F.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/F.png new file mode 100644 index 000000000..fd23c2b46 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/F.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/G.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/G.png new file mode 100644 index 000000000..e0a458439 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/G.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/H.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/H.png new file mode 100644 index 000000000..5f8e41fe2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/H.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/I.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/I.png new file mode 100644 index 000000000..8a54bccc2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/I.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/J.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/J.png new file mode 100644 index 000000000..e0c6c3a98 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/J.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/K.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/K.png new file mode 100644 index 000000000..6d99e483c Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/K.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/L.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/L.png new file mode 100644 index 000000000..434413add Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/L.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/M.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/M.png new file mode 100644 index 000000000..c15ebec1d Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/M.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/N.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/N.png new file mode 100644 index 000000000..2e00eedba Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/N.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/O.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/O.png new file mode 100644 index 000000000..c5b01dc2c Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/O.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/P.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/P.png new file mode 100644 index 000000000..2a30131bf Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/P.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/Q.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/Q.png new file mode 100644 index 000000000..672ec0159 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/Q.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/R.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/R.png new file mode 100644 index 000000000..438182e5c Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/R.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/S.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/S.png new file mode 100644 index 000000000..ddf3373e8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/S.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/T.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/T.png new file mode 100644 index 000000000..a5a467b48 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/T.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/U.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/U.png new file mode 100644 index 000000000..bef0ab8d6 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/U.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/V.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/V.png new file mode 100644 index 000000000..520ccf741 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/V.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/W.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/W.png new file mode 100644 index 000000000..8407f61dd Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/W.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/X.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/X.png new file mode 100644 index 000000000..53e839410 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/X.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/Y.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/Y.png new file mode 100644 index 000000000..49da48a18 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/Y.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/Z.png b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/Z.png new file mode 100644 index 000000000..64e62120b Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/digits/Z.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/generating-code.jpg b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/generating-code.jpg new file mode 100644 index 000000000..84bc808b7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/generating-code.jpg differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/linking-account.jpg b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/linking-account.jpg new file mode 100644 index 000000000..6a6618ad2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/linking-account.jpg differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/pin-display.jpg b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/pin-display.jpg new file mode 100644 index 000000000..cee0e3590 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/pin-display.jpg differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/plexpass.jpg b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/plexpass.jpg new file mode 100644 index 000000000..2e08355e8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/plexpass.jpg differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/pre-signin.jpg b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/pre-signin.jpg new file mode 100644 index 000000000..6b1f9ac54 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/pre-signin.jpg differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/sign_in/refresh-code.jpg b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/refresh-code.jpg new file mode 100644 index 000000000..8af7a04c2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/sign_in/refresh-code.jpg differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/splash.png b/script.plexmod/resources/skins/Main/media/script.plex/splash.png new file mode 100644 index 000000000..e7a2a53ac Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/splash.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/square-rounded-shadow.png b/script.plexmod/resources/skins/Main/media/script.plex/square-rounded-shadow.png new file mode 100644 index 000000000..e22480fde Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/square-rounded-shadow.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/stars/0.png b/script.plexmod/resources/skins/Main/media/script.plex/stars/0.png new file mode 100644 index 000000000..fd047749a Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/stars/0.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/stars/1.png b/script.plexmod/resources/skins/Main/media/script.plex/stars/1.png new file mode 100644 index 000000000..a338d5a02 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/stars/1.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/stars/2.png b/script.plexmod/resources/skins/Main/media/script.plex/stars/2.png new file mode 100644 index 000000000..b60232cc8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/stars/2.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/stars/3.png b/script.plexmod/resources/skins/Main/media/script.plex/stars/3.png new file mode 100644 index 000000000..eec791c98 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/stars/3.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/stars/4.png b/script.plexmod/resources/skins/Main/media/script.plex/stars/4.png new file mode 100644 index 000000000..b90e683bb Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/stars/4.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/stars/5.png b/script.plexmod/resources/skins/Main/media/script.plex/stars/5.png new file mode 100644 index 000000000..ab646cf5c Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/stars/5.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/broken-photo-thumb.png b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/broken-photo-thumb.png new file mode 100644 index 000000000..fbe365038 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/broken-photo-thumb.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/broken-photo.png b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/broken-photo.png new file mode 100644 index 000000000..b4330de71 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/broken-photo.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/movie.png b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/movie.png new file mode 100644 index 000000000..e847163b8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/movie.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/movie16x9.png b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/movie16x9.png new file mode 100644 index 000000000..7c2c92942 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/movie16x9.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/music.png b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/music.png new file mode 100644 index 000000000..9fb7042f7 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/music.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/photo.png b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/photo.png new file mode 100644 index 000000000..ecaff1e92 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/photo.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/role.png b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/role.png new file mode 100644 index 000000000..a3f4c0995 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/role.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/show.png b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/show.png new file mode 100644 index 000000000..b4eecad88 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/thumb_fallbacks/show.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/transparent-6px.png b/script.plexmod/resources/skins/Main/media/script.plex/transparent-6px.png new file mode 100644 index 000000000..12269aa19 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/transparent-6px.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/admin-back.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/admin-back.png new file mode 100644 index 000000000..2335b53bc Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/admin-back.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/admin-icon.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/admin-icon.png new file mode 100644 index 000000000..f98f65136 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/admin-icon.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/avatar-background.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/avatar-background.png new file mode 100644 index 000000000..03926d8f2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/avatar-background.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/avatar-diffuse.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/avatar-diffuse.png new file mode 100644 index 000000000..b375889ef Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/avatar-diffuse.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/backspace.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/backspace.png new file mode 100644 index 000000000..c5324296f Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/backspace.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/backspace_nf.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/backspace_nf.png new file mode 100644 index 000000000..9b6dd0bf2 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/backspace_nf.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/item-background-bottom.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/item-background-bottom.png new file mode 100644 index 000000000..f39ea934a Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/item-background-bottom.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/item-background-top.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/item-background-top.png new file mode 100644 index 000000000..803c7da69 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/item-background-top.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/item-background.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/item-background.png new file mode 100644 index 000000000..82f2aa605 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/item-background.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/plex.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/plex.png new file mode 100644 index 000000000..2fbd92adf Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/plex.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/protected-back.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/protected-back.png new file mode 100644 index 000000000..a318164bd Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/protected-back.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/user_select/protected-icon.png b/script.plexmod/resources/skins/Main/media/script.plex/user_select/protected-icon.png new file mode 100644 index 000000000..d6188176d Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/user_select/protected-icon.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-outline-rounded.png b/script.plexmod/resources/skins/Main/media/script.plex/white-outline-rounded.png new file mode 100644 index 000000000..3ebd26af8 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-outline-rounded.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-1px.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-1px.png new file mode 100644 index 000000000..d81bd9903 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square-1px.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-6px.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-6px.png new file mode 100644 index 000000000..5a1083bec Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square-6px.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-left-rounded.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-left-rounded.png new file mode 100644 index 000000000..ab2aba6fa Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square-left-rounded.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded-4r.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded-4r.png new file mode 100644 index 000000000..5617eda80 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded-4r.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded-top-padded.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded-top-padded.png new file mode 100644 index 000000000..d886c3b15 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded-top-padded.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded-with-shadow.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded-with-shadow.png new file mode 100644 index 000000000..4bab599d3 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded-with-shadow.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded.png new file mode 100644 index 000000000..c73f7a7fb Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square-rounded.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-tl-rounded.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-tl-rounded.png new file mode 100644 index 000000000..a02720a79 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square-tl-rounded.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square-top-rounded.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square-top-rounded.png new file mode 100644 index 000000000..c527e78b3 Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square-top-rounded.png differ diff --git a/script.plexmod/resources/skins/Main/media/script.plex/white-square.png b/script.plexmod/resources/skins/Main/media/script.plex/white-square.png new file mode 100644 index 000000000..52065d4ec Binary files /dev/null and b/script.plexmod/resources/skins/Main/media/script.plex/white-square.png differ diff --git a/script.plexmod/resources/skins/Main/skin.xml b/script.plexmod/resources/skins/Main/skin.xml new file mode 100644 index 000000000..757461a68 --- /dev/null +++ b/script.plexmod/resources/skins/Main/skin.xml @@ -0,0 +1,14 @@ + + + 1080i + 1080i + 1.0 + + + 0 + + + Main + Plex + + \ No newline at end of file diff --git a/script.plexmod/screensaver.py b/script.plexmod/screensaver.py new file mode 100644 index 000000000..8e15051bc --- /dev/null +++ b/script.plexmod/screensaver.py @@ -0,0 +1,24 @@ +import xbmc + +from lib import plex, util + +from lib.windows import slidehshow + +class ScreensaverMonitor(xbmc.Monitor): + def __init__( self, *args, **kwargs ): + self.action = kwargs['action'] + + def onScreensaverDeactivated(self): + self.action() + +def main(): + util.DEBUG_LOG("[SS] Starting") + if plex.init(): + with util.Cron(1): + ss = slidehshow.Slideshow.create() + ss.monitor = ScreensaverMonitor(action = ss.close) + ss.modal() + del ss + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/script.plexmod/service.py b/script.plexmod/service.py new file mode 100644 index 000000000..1698e9783 --- /dev/null +++ b/script.plexmod/service.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +from kodi_six import xbmc +from kodi_six import xbmcgui +from kodi_six import xbmcaddon + + +def main(): + if xbmc.getInfoLabel('Window(10000).Property(script.plex.service.started)'): + # Prevent add-on updates from starting a new version of the addon + return + + xbmcgui.Window(10000).setProperty('script.plex.service.started', '1') + + if xbmcaddon.Addon().getSetting('kiosk.mode') == 'true': + xbmc.log('script.plex: Starting from service (Kiosk Mode)', xbmc.LOGINFO) + xbmc.executebuiltin('RunScript(script.plexmod)') + + +if __name__ == '__main__': + main()