Selaa lähdekoodia

Initial commit

gaojiang 10 tuntia sitten
sitoutus
a1c285a215
100 muutettua tiedostoa jossa 13382 lisäystä ja 0 poistoa
  1. 13 0
      .gitignore
  2. 674 0
      LICENSE.md
  3. 243 0
      README.md
  4. 2 0
      app/.gitignore
  5. 183 0
      app/build.gradle
  6. 1 0
      app/libs/.gitignore
  7. 195 0
      app/proguard-rules-media.pro
  8. 69 0
      app/proguard-rules.pro
  9. 409 0
      app/schemas/com.fongmi.android.tv.db.AppDatabase/35.json
  10. 177 0
      app/src/leanback/AndroidManifest.xml
  11. 37 0
      app/src/leanback/java/com/fongmi/android/tv/Product.java
  12. 126 0
      app/src/leanback/java/com/fongmi/android/tv/Updater.java
  13. 48 0
      app/src/leanback/java/com/fongmi/android/tv/bean/Cache.java
  14. 60 0
      app/src/leanback/java/com/fongmi/android/tv/bean/Func.java
  15. 82 0
      app/src/leanback/java/com/fongmi/android/tv/dlna/CastAction.java
  16. 294 0
      app/src/leanback/java/com/fongmi/android/tv/dlna/DLNAAvTransportImpl.java
  17. 58 0
      app/src/leanback/java/com/fongmi/android/tv/dlna/DLNARenderingControlImpl.java
  18. 5 0
      app/src/leanback/java/com/fongmi/android/tv/dlna/RenderState.java
  19. 44 0
      app/src/leanback/java/com/fongmi/android/tv/receiver/BootReceiver.java
  20. 258 0
      app/src/leanback/java/com/fongmi/android/tv/service/DLNARendererService.java
  21. 521 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java
  22. 192 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java
  23. 73 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java
  24. 484 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java
  25. 102 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java
  26. 1069 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java
  27. 66 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/PushActivity.java
  28. 320 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/SearchActivity.java
  29. 346 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java
  30. 177 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java
  31. 1455 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
  32. 197 0
      app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java
  33. 80 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ArrayAdapter.java
  34. 19 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/BaseDiffCallback.java
  35. 99 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ChannelAdapter.java
  36. 63 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java
  37. 81 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java
  38. 67 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/DohAdapter.java
  39. 84 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpgDataAdapter.java
  40. 109 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java
  41. 65 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/FileAdapter.java
  42. 123 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/FlagAdapter.java
  43. 85 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/GroupAdapter.java
  44. 105 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java
  45. 120 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeyboardAdapter.java
  46. 81 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java
  47. 82 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ParseAdapter.java
  48. 78 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/PartAdapter.java
  49. 71 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QualityAdapter.java
  50. 85 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java
  51. 90 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/RecordAdapter.java
  52. 79 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/RestoreAdapter.java
  53. 115 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java
  54. 83 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java
  55. 47 0
      app/src/leanback/java/com/fongmi/android/tv/ui/adapter/WordAdapter.java
  56. 123 0
      app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java
  57. 61 0
      app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseFragment.java
  58. 18 0
      app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseVodHolder.java
  59. 8 0
      app/src/leanback/java/com/fongmi/android/tv/ui/base/ViewType.java
  60. 38 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomEditText.java
  61. 134 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownLive.java
  62. 114 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java
  63. 82 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyboard.java
  64. 59 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomLeftRightLayout.java
  65. 59 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomLiveListView.java
  66. 166 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomMic.java
  67. 43 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomRowPresenter.java
  68. 72 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomScroller.java
  69. 38 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomSearchView.java
  70. 72 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomSelector.java
  71. 84 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTextListener.java
  72. 97 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTitleView.java
  73. 60 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java
  74. 59 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomUpDownView.java
  75. 90 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomVerticalGridView.java
  76. 24 0
      app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomViewPager.java
  77. 53 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/BufferDialog.java
  78. 172 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java
  79. 33 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ContentDialog.java
  80. 66 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DohDialog.java
  81. 78 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java
  82. 96 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/LiveDialog.java
  83. 63 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PassDialog.java
  84. 68 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/RestoreDialog.java
  85. 135 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java
  86. 53 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SpeedDialog.java
  87. 120 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UaDialog.java
  88. 44 0
      app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java
  89. 142 0
      app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java
  90. 101 0
      app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java
  91. 295 0
      app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java
  92. 38 0
      app/src/leanback/java/com/fongmi/android/tv/ui/holder/VodListHolder.java
  93. 42 0
      app/src/leanback/java/com/fongmi/android/tv/ui/holder/VodOvalHolder.java
  94. 48 0
      app/src/leanback/java/com/fongmi/android/tv/ui/holder/VodRectHolder.java
  95. 57 0
      app/src/leanback/java/com/fongmi/android/tv/ui/presenter/FilterPresenter.java
  96. 52 0
      app/src/leanback/java/com/fongmi/android/tv/ui/presenter/FuncPresenter.java
  97. 39 0
      app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HeaderPresenter.java
  98. 99 0
      app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java
  99. 33 0
      app/src/leanback/java/com/fongmi/android/tv/ui/presenter/ProgressPresenter.java
  100. 63 0
      app/src/leanback/java/com/fongmi/android/tv/ui/presenter/VodPresenter.java

+ 13 - 0
.gitignore

@@ -0,0 +1,13 @@
+.idea
+.vscode
+.DS_Store
+.gradle
+.github
+.settings
+*build
+*.jks
+*.properties
+lib-*.aar
+**/Release
+**/output/
+*.apk

+ 674 - 0
LICENSE.md

@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.

+ 243 - 0
README.md

@@ -0,0 +1,243 @@
+# TV 定制版
+
+基于 FongMi TV 的 Android 影音应用,提供电视端(Leanback)和手机端(Mobile)两套界面,支持点播、直播以及外部配置。
+
+## 定制内容
+
+- 取消应用启动时的自动更新检测。
+- 首页根据配置动态显示入口:
+  - 未配置点播:显示“直播、设置”。
+  - 已配置点播:显示“点播、直播、设置”。
+- 设置页增加“启动时进入直播”选项。
+- 该选项首次安装默认开启,之后会记住用户选择。
+- 启动直播状态采用与“无痕模式”一致的“开/关”文字显示。
+- 预设直播配置;该地址也可以手动填写到点播配置:
+
+```text
+http://iptv.373720560.xyz/qwer1234
+```
+
+## 项目信息
+
+| 项目 | 内容 |
+| --- | --- |
+| 包名 | `com.fongmi.android.tv` |
+| 版本 | `5.4.1` |
+| 编译 SDK | API 37 |
+| 目标 SDK | API 28 |
+| 标准版最低系统 | Android 7.0(API 24) |
+| 老电视兼容版 | Android 6.0(API 23) |
+| CPU 架构 | `arm64-v8a`、`armeabi-v7a` |
+| 界面版本 | `leanback`、`mobile` |
+
+## 版本选择
+
+### 界面
+
+- `leanback`:电视、电视盒子,适合遥控器操作。
+- `mobile`:手机和平板,适合触摸操作。
+
+### CPU 架构
+
+- `arm64-v8a`:大多数较新的 64 位设备。
+- `armeabi-v7a`:32 位设备及部分只提供 32 位运行环境的老电视。
+
+CPU 是 64 位不代表系统一定支持 64 位应用。部分老电视应优先尝试 `armeabi-v7a`。
+
+### 老电视兼容包
+
+Android 6.0 电视可使用:
+
+```text
+Release/apk/leanback-armeabi_v7a-android6-debug.apk
+```
+
+该版本具有以下特点:
+
+- 最低支持 Android 6.0(API 23)。
+- 使用 `armeabi-v7a` 架构。
+- 同时包含 v1 和 v2 APK 签名。
+- 不包含 Chaquopy Python 引擎,因此 Python 类型的点播源不可用。
+- Java、CSP、QuickJS、普通 HTTP/HLS 点播与直播不受影响。
+
+Android 5.x 及更低版本暂不支持。
+
+## 配置行为
+
+### 点播
+
+点播配置 URL 非空时,首页会自动出现“点播”入口。删除点播配置后,该入口自动隐藏。
+
+配置支持 `sites`、`parses`、`lives`、`doh`、`proxy`、`hosts`、`ads` 等字段。
+
+### 直播
+
+支持常见的 JSON、M3U 和 TXT 直播源,包含频道分组、EPG、收藏等功能。
+
+### 启动进入直播
+
+路径:
+
+```text
+设置 -> 启动时进入直播
+```
+
+状态为“开”时,配置加载完成后会自动进入直播;状态为“关”时停留在首页。
+
+## 本地构建
+
+### 环境要求
+
+- macOS、Linux 或 Windows
+- JDK 21
+- Android SDK Platform 37
+- Android Build Tools
+
+在项目根目录创建 `local.properties`:
+
+```properties
+sdk.dir=/你的/Android/Sdk/路径
+```
+
+项目中的 `gradlew` 可能没有执行权限,可直接使用 `bash gradlew`。
+
+### 构建调试包
+
+电视 ARM64:
+
+```bash
+bash gradlew assembleLeanbackArm64_v8aDebug --no-build-cache
+```
+
+电视 32 位:
+
+```bash
+bash gradlew assembleLeanbackArmeabi_v7aDebug --no-build-cache
+```
+
+手机 ARM64:
+
+```bash
+bash gradlew assembleMobileArm64_v8aDebug --no-build-cache
+```
+
+手机 32 位:
+
+```bash
+bash gradlew assembleMobileArmeabi_v7aDebug --no-build-cache
+```
+
+### 构建 Android 6 老电视包
+
+```bash
+bash gradlew assembleLeanbackArmeabi_v7aDebug \
+  -Plegacy=true \
+  --no-build-cache
+```
+
+输出目录:
+
+```text
+app/build/outputs/apk/
+```
+
+### 构建 Release 包
+
+```bash
+bash gradlew assembleLeanbackArm64_v8aRelease --no-build-cache
+bash gradlew assembleLeanbackArmeabi_v7aRelease --no-build-cache
+```
+
+Release 构建完成后,APK 会复制到:
+
+```text
+Release/apk/
+```
+
+如果 `local.properties` 没有提供正式签名信息,Release 包会使用调试签名。
+
+正式签名配置示例:
+
+```properties
+storeFile=/绝对路径/release.jks
+keyAlias=你的别名
+storePassword=你的密码
+```
+
+## ADB 安装
+
+查看设备:
+
+```bash
+adb devices
+```
+
+覆盖安装并保留数据:
+
+```bash
+adb install -r 你的安装包.apk
+```
+
+如果提示签名不一致,需要先卸载旧版本:
+
+```bash
+adb uninstall com.fongmi.android.tv
+adb install 你的安装包.apk
+```
+
+卸载会清除应用配置和观看记录。
+
+## 常见问题
+
+### 安装包无法解析
+
+常见原因:
+
+1. 电视系统低于 APK 的最低 Android 版本。
+2. APK 架构与电视运行环境不一致。
+3. 老系统不支持仅使用 v2 签名的 APK。
+4. 下载或复制过程中 APK 文件损坏。
+
+Android 6 老电视优先使用老电视兼容包。
+
+### 提示应用未安装
+
+通常是已安装版本与新 APK 的签名不同。卸载旧版本后重新安装。
+
+### 配置了点播但首页没有入口
+
+确认地址填写在“点播配置”而不是“直播配置”。配置保存后,首页会自动刷新并显示“点播”。
+
+### 设置页崩溃
+
+本定制版已经移除存在兼容问题的 `MaterialSwitch`,改为与“无痕模式”一致的文字状态显示。
+
+## 目录结构
+
+```text
+TV/
+├── app/                 主应用
+│   └── src/
+│       ├── main/        公共业务逻辑
+│       ├── leanback/    电视界面
+│       ├── mobile/      手机界面
+│       └── legacy/      Android 6 兼容代码
+├── catvod/              Spider 与网络基础模块
+├── chaquo/              Python 引擎
+├── quickjs/             JavaScript 引擎
+├── docs/                配置及接口文档
+└── Release/apk/         Release 和兼容 APK
+```
+
+## 相关文档
+
+| 文档 | 内容 |
+| --- | --- |
+| [CONFIG.md](docs/CONFIG.md) | 点播及直播配置格式 |
+| [LIVE.md](docs/LIVE.md) | 直播源格式 |
+| [SPIDER.md](docs/SPIDER.md) | Spider 接口 |
+| [LOCAL.md](docs/LOCAL.md) | 本地 HTTP API |
+
+## 说明
+
+本项目仅提供播放器及配置加载能力。请确保使用的内容源具有合法授权,并遵守所在地区的法律法规。

+ 2 - 0
app/.gitignore

@@ -0,0 +1,2 @@
+/build
+/.cxx

+ 183 - 0
app/build.gradle

@@ -0,0 +1,183 @@
+plugins {
+    id 'com.android.application'
+}
+
+def legacyBuild = providers.gradleProperty("legacy").orNull == "true"
+
+def localProps = new Properties()
+def localPropsFile = rootProject.file('local.properties')
+if (localPropsFile.exists()) {
+    localPropsFile.withInputStream {
+        localProps.load(it)
+    }
+}
+
+android {
+    namespace = 'com.fongmi.android.tv'
+
+    compileSdk {
+        version = release(37)
+    }
+
+    defaultConfig {
+        applicationId "com.fongmi.android.tv"
+        minSdk legacyBuild ? 23 : 24
+        //noinspection ExpiredTargetSdkVersion
+        targetSdk 28
+        versionCode 541
+        versionName "5.4.1"
+        javaCompileOptions {
+            annotationProcessorOptions {
+                arguments = ["room.schemaLocation": "$projectDir/schemas".toString(), "eventBusIndex": "com.fongmi.android.tv.event.EventIndex"]
+            }
+        }
+    }
+
+    flavorDimensions = ["mode", "abi"]
+
+    productFlavors {
+        leanback {
+            dimension "mode"
+        }
+        mobile {
+            dimension "mode"
+        }
+        arm64_v8a {
+            dimension "abi"
+            ndk { abiFilters "arm64-v8a" }
+        }
+        armeabi_v7a {
+            dimension "abi"
+            ndk { abiFilters "armeabi-v7a" }
+        }
+    }
+
+    buildFeatures {
+        buildConfig = true
+        viewBinding = true
+    }
+
+    if (legacyBuild) {
+        sourceSets.main.java.srcDir("src/legacy/java")
+    }
+
+    signingConfigs {
+        debug {
+            enableV1Signing true
+            enableV2Signing true
+        }
+        if (localProps['storeFile'] && localProps['keyAlias'] && localProps['storePassword']) {
+            release {
+                storeFile file(localProps['storeFile'])
+                keyAlias localProps['keyAlias']
+                keyPassword localProps['storePassword']
+                storePassword localProps['storePassword']
+                enableV1Signing true
+                enableV2Signing true
+            }
+        }
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled = true
+            shrinkResources = true
+            signingConfig = signingConfigs.findByName('release') ?: signingConfigs.debug
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro', 'proguard-rules-media.pro'
+        }
+    }
+
+    packagingOptions {
+        resources {
+            exclude 'META-INF/beans.xml'
+            exclude 'META-INF/versions/9/OSGI-INF/MANIFEST.MF'
+        }
+    }
+
+    configurations.configureEach {
+        exclude group: 'com.android.support'
+        resolutionStrategy {
+            force 'com.squareup.okhttp3:okhttp:' + okhttpVersion
+        }
+    }
+
+    lint {
+        disable 'UnsafeOptInUsageError'
+    }
+
+    compileOptions {
+        coreLibraryDesugaringEnabled = true
+        sourceCompatibility JavaVersion.VERSION_17
+        targetCompatibility JavaVersion.VERSION_17
+    }
+}
+
+androidComponents {
+    onVariants(selector().withBuildType('release')) { variant ->
+        def flavors = variant.productFlavors.collectEntries { [(it.first): it.second] }
+        def mode = flavors['mode'] ?: 'mode'
+        def abi = flavors['abi'] ?: 'abi'
+        variant.outputs.each { output ->
+            output.outputFileName.set("${mode}-${abi}.apk")
+        }
+    }
+}
+
+dependencies {
+    implementation fileTree(dir: "libs", include: ["*.aar"])
+    implementation project(':catvod')
+    if (!legacyBuild) implementation project(':chaquo')
+    implementation project(':quickjs')
+    implementation 'androidx.appcompat:appcompat:1.7.1'
+    implementation 'androidx.core:core-splashscreen:1.2.0'
+    implementation 'androidx.lifecycle:lifecycle-service:2.10.0'
+    implementation 'androidx.media:media:1.7.1'
+    implementation 'androidx.media3:media3-common:' + media3Version
+    implementation 'androidx.media3:media3-container:' + media3Version
+    implementation 'androidx.media3:media3-database:' + media3Version
+    implementation 'androidx.media3:media3-datasource:' + media3Version
+    implementation 'androidx.media3:media3-datasource-okhttp:' + media3Version
+    implementation 'androidx.media3:media3-datasource-rtmp:' + media3Version
+    implementation 'androidx.media3:media3-decoder:' + media3Version
+    implementation 'androidx.media3:media3-effect:' + media3Version
+    implementation 'androidx.media3:media3-exoplayer:' + media3Version
+    implementation 'androidx.media3:media3-exoplayer-dash:' + media3Version
+    implementation 'androidx.media3:media3-exoplayer-hls:' + media3Version
+    implementation 'androidx.media3:media3-exoplayer-rtsp:' + media3Version
+    implementation 'androidx.media3:media3-exoplayer-smoothstreaming:' + media3Version
+    implementation 'androidx.media3:media3-extractor:' + media3Version
+    implementation 'androidx.media3:media3-session:' + media3Version
+    implementation 'androidx.media3:media3-ui:' + media3Version
+    implementation 'androidx.palette:palette:1.0.0'
+    implementation 'androidx.recyclerview:recyclerview:1.4.0'
+    implementation 'androidx.room:room-runtime:2.8.4'
+    implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0'
+    implementation 'androidx.viewpager2:viewpager2:1.1.0'
+    implementation 'cat.ereza:customactivityoncrash:2.4.0'
+    implementation 'com.airbnb.android:lottie:6.7.1'
+    implementation 'com.github.bassaer:materialdesigncolors:1.0.0'
+    implementation 'com.github.bumptech.glide:glide:' + glideVersion
+    implementation 'com.github.bumptech.glide:annotations:' + glideVersion
+    implementation 'com.github.bumptech.glide:avif-integration:' + glideVersion
+    implementation 'com.github.bumptech.glide:okhttp3-integration:' + glideVersion
+    implementation 'com.github.jahirfiquitiva:TextDrawable:1.0.3'
+    implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.26.1'
+    implementation 'com.google.android.flexbox:flexbox:3.0.0'
+    implementation 'com.google.android.material:material:1.14.0-beta01'
+    implementation 'com.guolindev.permissionx:permissionx:1.8.1'
+    implementation 'org.jupnp:org.jupnp:3.0.4'
+    implementation 'org.jupnp:org.jupnp.android:3.0.4'
+    implementation 'org.jupnp:org.jupnp.support:3.0.4'
+    implementation 'org.greenrobot:eventbus:3.3.1'
+    implementation 'org.nanohttpd:nanohttpd:2.3.1'
+    implementation('org.simpleframework:simple-xml:2.7.1') { exclude group: 'stax', module: 'stax-api' exclude group: 'xpp3', module: 'xpp3' }
+    implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.31'
+    leanbackImplementation 'androidx.leanback:leanback:1.2.0'
+    leanbackImplementation 'com.github.JessYanCoding:AndroidAutoSize:1.2.1'
+    mobileImplementation 'androidx.biometric:biometric:1.1.0'
+    mobileImplementation 'com.journeyapps:zxing-android-embedded:4.3.0'
+    annotationProcessor 'androidx.room:room-compiler:2.8.4'
+    annotationProcessor 'com.github.bumptech.glide:compiler:' + glideVersion
+    annotationProcessor 'org.greenrobot:eventbus-annotation-processor:3.3.1'
+    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
+}

+ 1 - 0
app/libs/.gitignore

@@ -0,0 +1 @@
+*.aar

+ 195 - 0
app/proguard-rules-media.pro

@@ -0,0 +1,195 @@
+-dontwarn android.content.res.**
+-dontwarn org.checkerframework.**
+-dontwarn kotlin.annotations.jvm.**
+-dontwarn java.lang.ClassValue
+-dontwarn java.lang.SafeVarargs
+-dontwarn sun.misc.Unsafe
+-dontwarn com.google.errorprone.annotations.**
+-dontwarn com.google.j2objc.annotations.**
+-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
+-dontwarn org.ietf.jgss.**
+-dontwarn org.kxml2.io.**
+-dontwarn org.xmlpull.v1.**
+-dontwarn org.slf4j.impl.StaticLoggerBinder
+-dontwarn org.conscrypt.**
+-dontwarn javax.**
+-dontwarn okio.**
+
+-keep class org.xmlpull.** { *; }
+-keepclassmembers class org.xmlpull.** { *; }
+
+-keepclassmembernames class com.google.common.base.Function { *; }
+
+-keep class com.hierynomus.** { *; }
+-keep class net.engio.mbassy.** { *; }
+
+-keepclasseswithmembernames class * {
+    native <methods>;
+}
+
+-keep class androidx.media3.decoder.VideoDecoderOutputBuffer { *; }
+-keep class androidx.media3.decoder.DecoderInputBuffer { *; }
+-keep class androidx.media3.decoder.av1.Dav1dDecoder { *; }
+-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
+-keep class androidx.media3.decoder.mpegh** { *; }
+
+-keepclassmembers class androidx.media3.datasource.RawResourceDataSource {
+  public static android.net.Uri buildRawResourceUri(int);
+}
+
+-dontnote androidx.media3.datasource.rtmp.RtmpDataSource
+-keepclassmembers class androidx.media3.datasource.rtmp.RtmpDataSource {
+  <init>();
+}
+
+-dontnote androidx.media3.decoder.vp9.LibvpxVideoRenderer
+-keepclassmembers class androidx.media3.decoder.vp9.LibvpxVideoRenderer {
+  <init>(long, android.os.Handler, androidx.media3.exoplayer.video.VideoRendererEventListener, int);
+}
+
+-dontnote androidx.media3.decoder.av1.Libdav1dVideoRenderer
+-keepclassmembers class androidx.media3.decoder.av1.Libdav1dVideoRenderer {
+  <init>(long, android.os.Handler, androidx.media3.exoplayer.video.VideoRendererEventListener, int);
+}
+
+-dontnote androidx.media3.decoder.ffmpeg.ExperimentalFfmpegVideoRenderer
+-keepclassmembers class androidx.media3.decoder.ffmpeg.ExperimentalFfmpegVideoRenderer {
+  <init>(long, android.os.Handler, androidx.media3.exoplayer.video.VideoRendererEventListener, int);
+}
+
+-dontnote androidx.media3.decoder.opus.LibopusAudioRenderer
+-keepclassmembers class androidx.media3.decoder.opus.LibopusAudioRenderer {
+  <init>(android.os.Handler, androidx.media3.exoplayer.audio.AudioRendererEventListener, androidx.media3.exoplayer.audio.AudioSink);
+}
+
+-dontnote androidx.media3.decoder.flac.LibflacAudioRenderer
+-keepclassmembers class androidx.media3.decoder.flac.LibflacAudioRenderer {
+  <init>(android.os.Handler, androidx.media3.exoplayer.audio.AudioRendererEventListener, androidx.media3.exoplayer.audio.AudioSink);
+}
+
+-dontnote androidx.media3.decoder.iamf.LibiamfAudioRenderer
+-keepclassmembers class androidx.media3.decoder.iamf.LibiamfAudioRenderer {
+  <init>(android.content.Context, android.os.Handler, androidx.media3.exoplayer.audio.AudioRendererEventListener, androidx.media3.exoplayer.audio.AudioSink);
+}
+
+-dontnote androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer
+-keepclassmembers class androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer {
+  <init>(android.os.Handler, androidx.media3.exoplayer.audio.AudioRendererEventListener, androidx.media3.exoplayer.audio.AudioSink);
+}
+
+-dontnote androidx.media3.decoder.midi.MidiRenderer
+-keepclassmembers class androidx.media3.decoder.midi.MidiRenderer {
+  <init>(android.content.Context, android.os.Handler, androidx.media3.exoplayer.audio.AudioRendererEventListener, androidx.media3.exoplayer.audio.AudioSink);
+}
+
+-dontnote androidx.media3.decoder.mpegh.MpeghAudioRenderer
+-keepclassmembers class androidx.media3.decoder.mpegh.MpeghAudioRenderer {
+  <init>(android.os.Handler, androidx.media3.exoplayer.audio.AudioRendererEventListener, androidx.media3.exoplayer.audio.AudioSink);
+}
+
+-dontnote androidx.media3.exoplayer.dash.offline.DashDownloader$Factory
+-keepclassmembers class androidx.media3.exoplayer.dash.offline.DashDownloader$Factory {
+  <init>(androidx.media3.datasource.cache.CacheDataSource$Factory);
+}
+
+-dontnote androidx.media3.exoplayer.hls.offline.HlsDownloader$Factory
+-keepclassmembers class androidx.media3.exoplayer.hls.offline.HlsDownloader$Factory {
+  <init>(androidx.media3.datasource.cache.CacheDataSource$Factory);
+}
+
+-dontnote androidx.media3.exoplayer.smoothstreaming.offline.SsDownloader$Factory
+-keepclassmembers class androidx.media3.exoplayer.smoothstreaming.offline.SsDownloader$Factory {
+  <init>(androidx.media3.datasource.cache.CacheDataSource$Factory);
+}
+
+-dontnote androidx.media3.exoplayer.dash.DashMediaSource$Factory
+-keepclasseswithmembers class androidx.media3.exoplayer.dash.DashMediaSource$Factory {
+  <init>(androidx.media3.datasource.DataSource$Factory);
+}
+
+-dontnote androidx.media3.exoplayer.hls.HlsMediaSource$Factory
+-keepclasseswithmembers class androidx.media3.exoplayer.hls.HlsMediaSource$Factory {
+  <init>(androidx.media3.datasource.DataSource$Factory);
+}
+
+-dontnote androidx.media3.exoplayer.smoothstreaming.SsMediaSource$Factory
+-keepclasseswithmembers class androidx.media3.exoplayer.smoothstreaming.SsMediaSource$Factory {
+  <init>(androidx.media3.datasource.DataSource$Factory);
+}
+
+-dontnote androidx.media3.exoplayer.rtsp.RtspMediaSource$Factory
+-keepclasseswithmembers class androidx.media3.exoplayer.rtsp.RtspMediaSource$Factory {
+  <init>();
+}
+
+-if class * implements androidx.media3.exoplayer.ExoPlayer {
+    public void setVideoEffects(java.util.List);
+}
+-keepclasseswithmembers class androidx.media3.effect.SingleInputVideoGraph$Factory {
+  <init>(androidx.media3.common.VideoFrameProcessor$Factory);
+}
+
+-if class * implements androidx.media3.exoplayer.ExoPlayer {
+    public void setVideoEffects(java.util.List);
+}
+-keepclasseswithmembers class androidx.media3.effect.DefaultVideoFrameProcessor$Factory$Builder {
+  <init>();
+  androidx.media3.effect.DefaultVideoFrameProcessor$Factory build();
+  androidx.media3.effect.DefaultVideoFrameProcessor$Factory$Builder setEnableReplayableCache(boolean);
+}
+
+-dontnote androidx.media3.effect.SingleInputVideoGraph$Factory
+-dontnote androidx.media3.effect.DefaultVideoFrameProcessor$Factory$Builder
+
+-dontnote androidx.media3.decoder.flac.FlacExtractor
+-keepclassmembers class androidx.media3.decoder.flac.FlacExtractor {
+  <init>(int);
+}
+
+-dontnote androidx.media3.decoder.flac.FlacLibrary
+-keepclassmembers class androidx.media3.decoder.flac.FlacLibrary {
+  public static boolean isAvailable();
+}
+
+-dontnote androidx.media3.decoder.midi.MidiExtractor
+-keepclassmembers class androidx.media3.decoder.midi.MidiExtractor {
+  <init>();
+}
+
+-dontnote androidx.media3.exoplayer.video.spherical.SphericalGLSurfaceView
+-keepclassmembers class androidx.media3.exoplayer.video.spherical.SphericalGLSurfaceView {
+  <init>(android.content.Context);
+}
+
+-dontnote androidx.media3.exoplayer.video.VideoDecoderGLSurfaceView
+-keepclassmembers class androidx.media3.exoplayer.video.VideoDecoderGLSurfaceView {
+  <init>(android.content.Context);
+}
+
+-keepnames class androidx.media3.exoplayer.ExoPlayer {}
+-keepclassmembers class androidx.media3.exoplayer.ExoPlayer {
+  void setImageOutput(androidx.media3.exoplayer.image.ImageOutput);
+  void setScrubbingModeEnabled(boolean);
+  boolean isScrubbingModeEnabled();
+}
+
+-keepclasseswithmembers class androidx.media3.exoplayer.image.ImageOutput {
+  void onImageAvailable(long, android.graphics.Bitmap);
+}
+
+-keepnames class androidx.media3.transformer.CompositionPlayer {}
+-keepclassmembers class androidx.media3.transformer.CompositionPlayer {
+  void setScrubbingModeEnabled(boolean);
+  boolean isScrubbingModeEnabled();
+}
+
+-dontnote androidx.appcompat.app.AlertDialog.Builder
+-keepclassmembers class androidx.appcompat.app.AlertDialog$Builder {
+  <init>(android.content.Context, int);
+  public android.content.Context getContext();
+  public androidx.appcompat.app.AlertDialog$Builder setTitle(java.lang.CharSequence);
+  public androidx.appcompat.app.AlertDialog$Builder setView(android.view.View);
+  public androidx.appcompat.app.AlertDialog$Builder setPositiveButton(int, android.content.DialogInterface$OnClickListener);
+  public androidx.appcompat.app.AlertDialog$Builder setNegativeButton(int, android.content.DialogInterface$OnClickListener);
+  public androidx.appcompat.app.AlertDialog create();
+}

+ 69 - 0
app/proguard-rules.pro

@@ -0,0 +1,69 @@
+# TV
+-keep class androidx.leanback.widget.** { *; }
+-keep class com.fongmi.quickjs.method.** { *; }
+-keep class com.fongmi.android.tv.bean.** { *; }
+
+# Gson
+-keep class com.google.gson.** { *; }
+
+# SimpleXML
+-keep interface org.simpleframework.xml.core.Label { public *; }
+-keep class * implements org.simpleframework.xml.core.Label { public *; }
+-keep interface org.simpleframework.xml.core.Parameter { public *; }
+-keep class * implements org.simpleframework.xml.core.Parameter { public *; }
+-keep interface org.simpleframework.xml.core.Extractor { public *; }
+-keep class * implements org.simpleframework.xml.core.Extractor { public *; }
+-keepclassmembers,allowobfuscation class * { @org.simpleframework.xml.Path <fields>; }
+-keepclassmembers,allowobfuscation class * { @org.simpleframework.xml.Root <fields>; }
+-keepclassmembers,allowobfuscation class * { @org.simpleframework.xml.Text <fields>; }
+-keepclassmembers,allowobfuscation class * { @org.simpleframework.xml.Element <fields>; }
+-keepclassmembers,allowobfuscation class * { @org.simpleframework.xml.Attribute <fields>; }
+-keepclassmembers,allowobfuscation class * { @org.simpleframework.xml.ElementList <fields>; }
+
+# OkHttp
+-dontwarn okhttp3.**
+-keep class okio.** { *; }
+-keep class okhttp3.** { *; }
+
+# CatVod
+-keep class com.github.catvod.Proxy { *; }
+-keep class com.github.catvod.crawler.** { *; }
+-keep class * extends com.github.catvod.crawler.Spider
+
+# Jianpian
+-keep class com.p2p.** { *; }
+
+# JUPnP
+-dontwarn org.jupnp.**
+-keep class org.jupnp.** { *; }
+-keep class javax.xml.** { *; }
+
+# Nano
+-keep class fi.iki.elonen.** { *; }
+
+# NewPipeExtractor
+-keep class javax.script.** { *; }
+-keep class jdk.dynalink.** { *; }
+-keep class org.mozilla.javascript.* { *; }
+-keep class org.mozilla.javascript.** { *; }
+-keep class org.mozilla.javascript.engine.** { *; }
+-keep class org.mozilla.classfile.ClassFileWriter
+-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; }
+-keep class org.schabi.newpipe.extractor.services.youtube.protos.** { *; }
+-dontwarn org.mozilla.javascript.JavaToJSONConverters
+-dontwarn org.mozilla.javascript.tools.**
+-dontwarn com.google.re2j.**
+-dontwarn javax.script.**
+-dontwarn jdk.dynalink.**
+
+# Sardine
+-keep class com.thegrizzlylabs.sardineandroid.** { *; }
+
+# TVBus
+-keep class com.tvbus.engine.** { *; }
+
+# XunLei
+-keep class com.xunlei.downloadlib.** { *; }
+
+# Zxing
+-keep class com.google.zxing.** { *; }

+ 409 - 0
app/schemas/com.fongmi.android.tv.db.AppDatabase/35.json

@@ -0,0 +1,409 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 35,
+    "identityHash": "8191bb223b1cde184fbb43d20d39d851",
+    "entities": [
+      {
+        "tableName": "Keep",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `siteName` TEXT, `vodName` TEXT, `vodPic` TEXT, `createTime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "siteName",
+            "columnName": "siteName",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "vodName",
+            "columnName": "vodName",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "vodPic",
+            "columnName": "vodPic",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "createTime",
+            "columnName": "createTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cid",
+            "columnName": "cid",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "Site",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `searchable` INTEGER, `changeable` INTEGER, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "searchable",
+            "columnName": "searchable",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "changeable",
+            "columnName": "changeable",
+            "affinity": "INTEGER"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "Live",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `keep` TEXT, `boot` INTEGER NOT NULL, `pass` INTEGER NOT NULL, PRIMARY KEY(`name`))",
+        "fields": [
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "keep",
+            "columnName": "keep",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "boot",
+            "columnName": "boot",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pass",
+            "columnName": "pass",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "name"
+          ]
+        }
+      },
+      {
+        "tableName": "Track",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `key` TEXT, `name` TEXT, `format` TEXT, `selected` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "format",
+            "columnName": "format",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "selected",
+            "columnName": "selected",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_Track_key_type",
+            "unique": true,
+            "columnNames": [
+              "key",
+              "type"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Track_key_type` ON `${TABLE_NAME}` (`key`, `type`)"
+          }
+        ]
+      },
+      {
+        "tableName": "Config",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `time` INTEGER NOT NULL, `url` TEXT, `json` TEXT, `name` TEXT, `logo` TEXT, `home` TEXT, `parse` TEXT)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "json",
+            "columnName": "json",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "logo",
+            "columnName": "logo",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "home",
+            "columnName": "home",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "parse",
+            "columnName": "parse",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_Config_url_type",
+            "unique": true,
+            "columnNames": [
+              "url",
+              "type"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)"
+          }
+        ]
+      },
+      {
+        "tableName": "Device",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uuid` TEXT, `name` TEXT, `ip` TEXT, `type` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "uuid",
+            "columnName": "uuid",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ip",
+            "columnName": "ip",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_Device_uuid_name",
+            "unique": true,
+            "columnNames": [
+              "uuid",
+              "name"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Device_uuid_name` ON `${TABLE_NAME}` (`uuid`, `name`)"
+          }
+        ]
+      },
+      {
+        "tableName": "History",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `vodPic` TEXT, `vodName` TEXT, `vodFlag` TEXT, `vodRemarks` TEXT, `episodeUrl` TEXT, `revSort` INTEGER NOT NULL, `revPlay` INTEGER NOT NULL, `createTime` INTEGER NOT NULL, `opening` INTEGER NOT NULL, `ending` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `speed` REAL NOT NULL, `scale` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "vodPic",
+            "columnName": "vodPic",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "vodName",
+            "columnName": "vodName",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "vodFlag",
+            "columnName": "vodFlag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "vodRemarks",
+            "columnName": "vodRemarks",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "episodeUrl",
+            "columnName": "episodeUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "revSort",
+            "columnName": "revSort",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "revPlay",
+            "columnName": "revPlay",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createTime",
+            "columnName": "createTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "opening",
+            "columnName": "opening",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "ending",
+            "columnName": "ending",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "duration",
+            "columnName": "duration",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "speed",
+            "columnName": "speed",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "scale",
+            "columnName": "scale",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cid",
+            "columnName": "cid",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        }
+      }
+    ],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8191bb223b1cde184fbb43d20d39d851')"
+    ]
+  }
+}

+ 177 - 0
app/src/leanback/AndroidManifest.xml

@@ -0,0 +1,177 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-feature
+        android:name="android.hardware.touchscreen"
+        android:required="false" />
+
+    <uses-feature
+        android:name="android.software.leanback"
+        android:required="false" />
+
+    <uses-feature
+        android:name="android.hardware.microphone"
+        android:required="false" />
+
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+
+    <application android:banner="@mipmap/ic_banner">
+
+        <meta-data
+            android:name="design_width_in_dp"
+            android:value="960" />
+
+        <meta-data
+            android:name="design_height_in_dp"
+            android:value="540" />
+
+        <activity
+            android:name=".ui.activity.HomeActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:exported="true"
+            android:launchMode="singleTop"
+            android:screenOrientation="sensorLandscape"
+            android:theme="@style/Theme.Splash">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.SEARCH" />
+            </intent-filter>
+            <meta-data
+                android:name="android.app.searchable"
+                android:resource="@xml/searchable" />
+            <intent-filter>
+                <action android:name="android.intent.action.SEND" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:mimeType="text/plain" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="content" />
+                <data android:scheme="file" />
+                <data android:mimeType="video/*" />
+                <data android:mimeType="audio/*" />
+                <data android:mimeType="text/plain" />
+                <data android:mimeType="application/x-bittorrent" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="smb" />
+                <data android:scheme="rtmp" />
+                <data android:scheme="rtsp" />
+                <data android:scheme="http" />
+                <data android:scheme="https" />
+                <data android:mimeType="video/*" />
+                <data android:mimeType="audio/*" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="ed2k" />
+                <data android:scheme="magnet" />
+                <data android:scheme="thunder" />
+                <data android:scheme="jianpian" />
+            </intent-filter>
+        </activity>
+
+        <activity
+            android:name=".ui.activity.CastActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:exported="true"
+            android:launchMode="singleTop"
+            android:screenOrientation="sensorLandscape">
+            <intent-filter>
+                <action android:name="${applicationId}.cast" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+
+        <activity
+            android:name=".ui.activity.VodActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:screenOrientation="sensorLandscape" />
+
+        <activity
+            android:name=".ui.activity.LiveActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:exported="true"
+            android:launchMode="singleTop"
+            android:parentActivityName=".ui.activity.HomeActivity"
+            android:screenOrientation="sensorLandscape" />
+
+        <activity
+            android:name=".ui.activity.VideoActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden|navigation"
+            android:launchMode="singleTop"
+            android:parentActivityName=".ui.activity.HomeActivity"
+            android:screenOrientation="sensorLandscape" />
+
+        <activity
+            android:name=".ui.activity.KeepActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:screenOrientation="sensorLandscape" />
+
+        <activity
+            android:name=".ui.activity.PushActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:screenOrientation="sensorLandscape" />
+
+        <activity
+            android:name=".ui.activity.SearchActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:screenOrientation="sensorLandscape"
+            android:windowSoftInputMode="stateHidden" />
+
+        <activity
+            android:name=".ui.activity.CollectActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:screenOrientation="sensorLandscape" />
+
+        <activity
+            android:name=".ui.activity.FileActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:screenOrientation="sensorLandscape" />
+
+        <activity
+            android:name=".ui.activity.SettingActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:screenOrientation="sensorLandscape" />
+
+        <activity
+            android:name=".ui.activity.SettingPlayerActivity"
+            android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+            android:screenOrientation="sensorLandscape" />
+
+        <service
+            android:name=".service.DLNARendererService"
+            android:exported="false"
+            android:foregroundServiceType="mediaPlayback" />
+
+        <receiver
+            android:name=".receiver.BootReceiver"
+            android:exported="true"
+            android:priority="999">
+            <intent-filter>
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
+                <action android:name="android.intent.action.QUICKBOOT_POWERON" />
+            </intent-filter>
+        </receiver>
+
+    </application>
+</manifest>

+ 37 - 0
app/src/leanback/java/com/fongmi/android/tv/Product.java

@@ -0,0 +1,37 @@
+package com.fongmi.android.tv;
+
+import com.fongmi.android.tv.bean.Style;
+import com.fongmi.android.tv.utils.ResUtil;
+
+public class Product {
+
+    public static int getDeviceType() {
+        return 0;
+    }
+
+    public static int getColumn() {
+        return Math.abs(Setting.getSize() - 7);
+    }
+
+    public static int getColumn(Style style) {
+        return style.isLand() ? getColumn() - 1 : getColumn();
+    }
+
+    public static int[] getSpec(Style style) {
+        int column = getColumn(style);
+        int space = ResUtil.dp2px(48) + ResUtil.dp2px(16 * (column - 1));
+        if (style.isOval()) space += ResUtil.dp2px(column * 16);
+        return getSpec(space, column, style);
+    }
+
+    public static int[] getSpec(int space, int column, Style style) {
+        int base = ResUtil.getScreenWidth() - space;
+        int width = base / column;
+        int height = (int) (width / style.getRatio());
+        return new int[]{width, height};
+    }
+
+    public static int getEms() {
+        return Math.min(Math.round((float) ResUtil.getScreenWidth() / ResUtil.sp2px(24)), 35);
+    }
+}

+ 126 - 0
app/src/leanback/java/com/fongmi/android/tv/Updater.java

@@ -0,0 +1,126 @@
+package com.fongmi.android.tv;
+
+import android.app.Activity;
+import android.view.LayoutInflater;
+import android.view.View;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.fongmi.android.tv.databinding.DialogUpdateBinding;
+import com.fongmi.android.tv.utils.Download;
+import com.fongmi.android.tv.utils.FileUtil;
+import com.fongmi.android.tv.utils.Github;
+import com.fongmi.android.tv.utils.Notify;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.fongmi.android.tv.utils.Task;
+import com.github.catvod.net.OkHttp;
+import com.github.catvod.utils.Path;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.json.JSONObject;
+
+import java.io.File;
+import java.util.Locale;
+
+public class Updater implements Download.Callback {
+
+    private DialogUpdateBinding binding;
+    private final Download download;
+    private AlertDialog dialog;
+
+    private File getFile() {
+        return Path.cache("update.apk");
+    }
+
+    private String getJson() {
+        return Github.getJson(BuildConfig.FLAVOR_mode);
+    }
+
+    private String getApk() {
+        return Github.getApk(BuildConfig.FLAVOR_mode + "-" + BuildConfig.FLAVOR_abi);
+    }
+
+    public static Updater create() {
+        return new Updater();
+    }
+
+    public Updater() {
+        this.download = Download.create(getApk(), getFile());
+    }
+
+    public Updater force() {
+        Notify.show(R.string.update_check);
+        Setting.putUpdate(true);
+        return this;
+    }
+
+    private Updater check() {
+        dismiss();
+        return this;
+    }
+
+    public void start(Activity activity) {
+        if (!Setting.getUpdate()) return;
+        Task.execute(() -> doInBackground(activity));
+    }
+
+    private void doInBackground(Activity activity) {
+        try {
+            JSONObject object = new JSONObject(OkHttp.string(getJson()));
+            String name = object.optString("name");
+            String desc = object.optString("desc");
+            int code = object.optInt("code");
+            if (code > BuildConfig.VERSION_CODE) App.post(() -> show(activity, name, desc));
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+    private void show(Activity activity, String version, String desc) {
+        binding = DialogUpdateBinding.inflate(LayoutInflater.from(activity));
+        binding.version.setText(ResUtil.getString(R.string.update_version, version));
+        binding.confirm.setOnClickListener(this::confirm);
+        binding.cancel.setOnClickListener(this::cancel);
+        check().create(activity).show();
+        binding.desc.setText(desc);
+    }
+
+    private AlertDialog create(Activity activity) {
+        return dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).setCancelable(false).create();
+    }
+
+    private void cancel(View view) {
+        Setting.putUpdate(false);
+        download.cancel();
+        dismiss();
+    }
+
+    private void confirm(View view) {
+        view.setEnabled(false);
+        download.start(this);
+    }
+
+    private void dismiss() {
+        try {
+            if (dialog != null) dialog.dismiss();
+        } catch (Exception ignored) {
+        }
+    }
+
+    @Override
+    public void progress(int progress) {
+        binding.confirm.setText(String.format(Locale.getDefault(), "%1$d%%", progress));
+    }
+
+    @Override
+    public void error(String msg) {
+        Notify.show(msg);
+        dismiss();
+    }
+
+    @Override
+    public void success(File file) {
+        FileUtil.openFile(file);
+        dismiss();
+    }
+}

+ 48 - 0
app/src/leanback/java/com/fongmi/android/tv/bean/Cache.java

@@ -0,0 +1,48 @@
+package com.fongmi.android.tv.bean;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class Cache {
+
+    private final Map<String, List<Filter>> cache;
+
+    private static class Loader {
+        static volatile Cache INSTANCE = new Cache();
+    }
+
+    private static Cache get() {
+        return Loader.INSTANCE;
+    }
+
+    public Cache() {
+        cache = new HashMap<>();
+    }
+
+    public void put(Result result) {
+        if (result == null) return;
+        result.getTypes().forEach(type -> get().cache.put(type.getTypeId(), type.getFilters()));
+    }
+
+    public static List<Filter> get(Class type) {
+        return get(type.getTypeId());
+    }
+
+    public static List<Filter> get(String typeId) {
+        List<Filter> filters = get().cache.get(typeId);
+        return filters == null ? Collections.emptyList() : filters;
+    }
+
+    public static List<Filter> copy(String typeId) {
+        List<Filter> filters = get().cache.get(typeId);
+        if (filters == null) return Collections.emptyList();
+        return filters.stream().map(Filter::copy).toList();
+    }
+
+    public static Cache clear() {
+        get().cache.clear();
+        return get();
+    }
+}

+ 60 - 0
app/src/leanback/java/com/fongmi/android/tv/bean/Func.java

@@ -0,0 +1,60 @@
+package com.fongmi.android.tv.bean;
+
+import androidx.annotation.Nullable;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.impl.Diffable;
+import com.fongmi.android.tv.utils.ResUtil;
+
+public class Func implements Diffable<Func> {
+
+    private final int resId;
+    private int drawable;
+
+    public static Func create(int resId) {
+        return new Func(resId);
+    }
+
+    public Func(int resId) {
+        this.resId = resId;
+        this.setDrawable();
+    }
+
+    public int getResId() {
+        return resId;
+    }
+
+    public int getDrawable() {
+        return drawable;
+    }
+
+    public String getText() {
+        return ResUtil.getString(resId);
+    }
+
+    public void setDrawable() {
+        if (resId == R.string.home_vod) this.drawable = R.drawable.ic_home_vod;
+        else if (resId == R.string.home_live) this.drawable = R.drawable.ic_home_live;
+        else if (resId == R.string.home_keep) this.drawable = R.drawable.ic_home_keep;
+        else if (resId == R.string.home_push) this.drawable = R.drawable.ic_home_push;
+        else if (resId == R.string.home_search) this.drawable = R.drawable.ic_home_search;
+        else if (resId == R.string.home_setting) this.drawable = R.drawable.ic_home_setting;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (this == obj) return true;
+        if (!(obj instanceof Func it)) return false;
+        return getResId() == it.getResId();
+    }
+
+    @Override
+    public boolean isSameItem(Func other) {
+        return equals(other);
+    }
+
+    @Override
+    public boolean isSameContent(Func other) {
+        return equals(other);
+    }
+}

+ 82 - 0
app/src/leanback/java/com/fongmi/android/tv/dlna/CastAction.java

@@ -0,0 +1,82 @@
+package com.fongmi.android.tv.dlna;
+
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.fongmi.android.tv.bean.Result;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class CastAction implements Parcelable {
+
+    public static final String KEY_EXTRA = "cast_action";
+
+    private final String currentURI;
+    private final String currentURIMetaData;
+    private final Map<String, String> headers;
+
+    public CastAction(String currentURI, String currentURIMetaData, Map<String, String> headers) {
+        this.currentURI = currentURI != null ? currentURI : "";
+        this.currentURIMetaData = currentURIMetaData != null ? currentURIMetaData : "";
+        this.headers = headers != null ? headers : new HashMap<>();
+    }
+
+    protected CastAction(Parcel in) {
+        currentURI = in.readString();
+        currentURIMetaData = in.readString();
+        Bundle bundle = in.readBundle(getClass().getClassLoader());
+        headers = new HashMap<>();
+        if (bundle != null) {
+            for (String key : bundle.keySet()) {
+                headers.put(key, bundle.getString(key));
+            }
+        }
+    }
+
+    public String getCurrentURI() {
+        return currentURI;
+    }
+
+    public String getCurrentURIMetaData() {
+        return currentURIMetaData;
+    }
+
+    public Map<String, String> getHeaders() {
+        return headers;
+    }
+
+    public Result result() {
+        Result result = new Result();
+        result.setUrl(getCurrentURI());
+        result.setHeader(getHeaders());
+        return result;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeString(currentURI);
+        dest.writeString(currentURIMetaData);
+        Bundle bundle = new Bundle();
+        headers.forEach(bundle::putString);
+        dest.writeBundle(bundle);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    public static final Creator<CastAction> CREATOR = new Creator<>() {
+        @Override
+        public CastAction createFromParcel(Parcel in) {
+            return new CastAction(in);
+        }
+
+        @Override
+        public CastAction[] newArray(int size) {
+            return new CastAction[size];
+        }
+    };
+}

+ 294 - 0
app/src/leanback/java/com/fongmi/android/tv/dlna/DLNAAvTransportImpl.java

@@ -0,0 +1,294 @@
+package com.fongmi.android.tv.dlna;
+
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.media3.common.Player;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.player.PlayerManager;
+import com.fongmi.android.tv.ui.activity.CastActivity;
+import com.google.gson.reflect.TypeToken;
+
+import org.jupnp.model.types.UnsignedIntegerFourBytes;
+import org.jupnp.support.avtransport.AbstractAVTransportService;
+import org.jupnp.support.avtransport.lastchange.AVTransportVariable;
+import org.jupnp.support.contentdirectory.DIDLParser;
+import org.jupnp.support.model.DIDLContent;
+import org.jupnp.support.model.DIDLObject;
+import org.jupnp.support.model.DeviceCapabilities;
+import org.jupnp.support.model.MediaInfo;
+import org.jupnp.support.model.PlayMode;
+import org.jupnp.support.model.PositionInfo;
+import org.jupnp.support.model.RecordQualityMode;
+import org.jupnp.support.model.SeekMode;
+import org.jupnp.support.model.StorageMedium;
+import org.jupnp.support.model.TransportAction;
+import org.jupnp.support.model.TransportInfo;
+import org.jupnp.support.model.TransportSettings;
+import org.jupnp.support.model.TransportState;
+import org.jupnp.support.model.TransportStatus;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+public class DLNAAvTransportImpl extends AbstractAVTransportService {
+
+    private final Context context;
+
+    private volatile PlayerManager player;
+    private volatile PlayMode currentPlayMode = PlayMode.NORMAL;
+    private volatile TransportState currentState = TransportState.NO_MEDIA_PRESENT;
+
+    private volatile String nextURI = "";
+    private volatile String currentURI = "";
+    private volatile String nextMetaData = "";
+    private volatile String currentMetaData = "";
+
+    private volatile boolean dlnaActive;
+    private volatile long pendingSeekMs = -1;
+    private volatile long cachedPosition = -1;
+    private volatile long cachedDuration = -1;
+
+    public DLNAAvTransportImpl(Context context) {
+        this.context = context;
+    }
+
+    public void setPlayerManager(PlayerManager player) {
+        this.player = player;
+        if (player == null) reset();
+    }
+
+    public void setDlnaActive(boolean active) {
+        this.dlnaActive = active;
+    }
+
+    public void reset() {
+        nextURI = "";
+        currentURI = "";
+        nextMetaData = "";
+        pendingSeekMs = -1;
+        cachedPosition = -1;
+        cachedDuration = -1;
+        currentMetaData = "";
+        currentPlayMode = PlayMode.NORMAL;
+        fireStateChange(RenderState.IDLE);
+    }
+
+    public void updatePositionCache(long position, long duration) {
+        cachedPosition = position;
+        cachedDuration = duration;
+    }
+
+    public long consumePendingSeekMs() {
+        long ms = pendingSeekMs;
+        pendingSeekMs = -1;
+        return ms;
+    }
+
+    @Override
+    public UnsignedIntegerFourBytes[] getCurrentInstanceIds() {
+        return new UnsignedIntegerFourBytes[]{getDefaultInstanceID()};
+    }
+
+    @Override
+    public synchronized void setAVTransportURI(UnsignedIntegerFourBytes instanceId, String currentURI, String currentURIMetaData) {
+        this.nextURI = "";
+        this.nextMetaData = "";
+        this.dlnaActive = false;
+        this.pendingSeekMs = -1;
+        this.currentURI = currentURI != null ? currentURI : "";
+        this.currentMetaData = currentURIMetaData != null ? currentURIMetaData : "";
+        startCastActivity(new CastAction(this.currentURI, this.currentMetaData, parseHeaders(this.currentMetaData)));
+    }
+
+    private Map<String, String> parseHeaders(String metaData) {
+        try {
+            DIDLContent content = new DIDLParser().parse(metaData);
+            return content.getItems().stream().flatMap(item -> item.getProperties().stream()).filter(p -> p instanceof DIDLObject.Property.DC.DESCRIPTION).findFirst().map(p -> App.gson().<Map<String, String>>fromJson(p.getValue().toString(), new TypeToken<Map<String, String>>() {}.getType())).orElse(new HashMap<>());
+        } catch (Exception ignored) {
+            return new HashMap<>();
+        }
+    }
+
+    @Override
+    public synchronized void setNextAVTransportURI(UnsignedIntegerFourBytes instanceId, String nextURI, String nextURIMetaData) {
+        this.nextURI = nextURI != null ? nextURI : "";
+        this.nextMetaData = nextURIMetaData != null ? nextURIMetaData : "";
+    }
+
+    @Override
+    public MediaInfo getMediaInfo(UnsignedIntegerFourBytes instanceId) {
+        String nURI = nextURI.isEmpty() ? "" : nextURI;
+        String nMeta = nextURI.isEmpty() ? "" : nextMetaData;
+        String durStr = cachedDuration > 0 ? formatMs(cachedDuration) : "00:00:00";
+        return new MediaInfo(currentURI, currentMetaData, nURI, nMeta, new UnsignedIntegerFourBytes(1), durStr, StorageMedium.NETWORK);
+    }
+
+    @Override
+    public TransportInfo getTransportInfo(UnsignedIntegerFourBytes instanceId) {
+        String speed = (player != null && dlnaActive) ? String.valueOf(player.getSpeed()) : "1";
+        return new TransportInfo(currentState, TransportStatus.OK, speed);
+    }
+
+    @Override
+    public PositionInfo getPositionInfo(UnsignedIntegerFourBytes instanceId) {
+        long posMs = cachedPosition;
+        long durMs = cachedDuration;
+        if (posMs < 0) return new PositionInfo(1, currentMetaData, currentURI);
+        String relTime = formatMs(posMs);
+        String durStr = durMs > 0 ? formatMs(durMs) : "00:00:00";
+        return new PositionInfo(1, durStr, currentMetaData, currentURI, relTime, relTime, Integer.MAX_VALUE, Integer.MAX_VALUE);
+    }
+
+    @Override
+    public DeviceCapabilities getDeviceCapabilities(UnsignedIntegerFourBytes instanceId) {
+        return new DeviceCapabilities(new StorageMedium[]{StorageMedium.NETWORK});
+    }
+
+    @Override
+    public TransportSettings getTransportSettings(UnsignedIntegerFourBytes instanceId) {
+        return new TransportSettings(currentPlayMode, RecordQualityMode.NOT_IMPLEMENTED);
+    }
+
+    @Override
+    public void stop(UnsignedIntegerFourBytes instanceId) {
+        fireStateChange(RenderState.STOPPED);
+        App.post(() -> {
+            if (player != null && dlnaActive) player.stop();
+        });
+    }
+
+    @Override
+    public void play(UnsignedIntegerFourBytes instanceId, String speed) {
+        App.post(() -> {
+            if (player == null || !dlnaActive) return;
+            int state = player.getPlaybackState();
+            if (!currentURI.isEmpty() && (state == Player.STATE_ENDED || state == Player.STATE_IDLE)) {
+                startCastActivity(new CastAction(currentURI, currentMetaData, parseHeaders(currentMetaData)));
+            } else {
+                player.play();
+            }
+        });
+    }
+
+    @Override
+    public void pause(UnsignedIntegerFourBytes instanceId) {
+        App.post(() -> {
+            if (player != null && dlnaActive) player.pause();
+        });
+    }
+
+    @Override
+    public void record(UnsignedIntegerFourBytes instanceId) {
+        throw new UnsupportedOperationException("Recording not supported");
+    }
+
+    @Override
+    public void seek(UnsignedIntegerFourBytes instanceId, String unit, String target) {
+        if (!SeekMode.REL_TIME.toString().equals(unit) && !SeekMode.ABS_TIME.toString().equals(unit)) return;
+        long ms = parseTimeToMs(target);
+        if (dlnaActive) {
+            PlayerManager local = player;
+            if (local != null) App.post(() -> local.seekTo(ms));
+        } else {
+            pendingSeekMs = ms;
+        }
+    }
+
+    @Override
+    public void next(UnsignedIntegerFourBytes instanceId) {
+        if (!dlnaActive) return;
+        CastAction action = popNext();
+        if (action != null) startCastActivity(action);
+    }
+
+    @Override
+    public void previous(UnsignedIntegerFourBytes instanceId) {
+        App.post(() -> {
+            if (player != null && dlnaActive) player.seekTo(0);
+        });
+    }
+
+    @Override
+    public void setPlayMode(UnsignedIntegerFourBytes instanceId, String newPlayMode) {
+        boolean repeat = PlayMode.REPEAT_ONE.name().equals(newPlayMode);
+        currentPlayMode = repeat ? PlayMode.REPEAT_ONE : PlayMode.NORMAL;
+        App.post(() -> {
+            if (player != null && dlnaActive) player.setRepeatOne(repeat);
+        });
+    }
+
+    @Override
+    public void setRecordQualityMode(UnsignedIntegerFourBytes instanceId, String newRecordQualityMode) {
+        throw new UnsupportedOperationException("Recording not supported");
+    }
+
+    @Override
+    protected TransportAction[] getCurrentTransportActions(UnsignedIntegerFourBytes instanceId) {
+        return switch (currentState) {
+            case PLAYING -> new TransportAction[]{TransportAction.Pause, TransportAction.Stop, TransportAction.Seek};
+            case PAUSED_PLAYBACK -> new TransportAction[]{TransportAction.Play, TransportAction.Stop, TransportAction.Seek};
+            default -> new TransportAction[]{TransportAction.Play};
+        };
+    }
+
+    private void startCastActivity(CastAction action) {
+        Intent intent = new Intent(context, CastActivity.class);
+        intent.putExtra(CastAction.KEY_EXTRA, action);
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+        context.startActivity(intent);
+    }
+
+    public boolean hasNext() {
+        return !nextURI.isEmpty();
+    }
+
+    public synchronized CastAction popNext() {
+        if (nextURI.isEmpty()) return null;
+        currentURI = nextURI;
+        currentMetaData = nextMetaData;
+        nextURI = "";
+        nextMetaData = "";
+        return new CastAction(currentURI, currentMetaData, parseHeaders(currentMetaData));
+    }
+
+    public void fireStateChange(RenderState state) {
+        try {
+            currentState = toTransportState(state);
+            getLastChange().setEventedValue(AbstractAVTransportService.getDefaultInstanceID(), new AVTransportVariable.TransportState(currentState));
+            getLastChange().fire(propertyChangeSupport);
+        } catch (Exception ignored) {
+        }
+    }
+
+    private TransportState toTransportState(RenderState state) {
+        return switch (state) {
+            case PLAYING -> TransportState.PLAYING;
+            case PAUSED -> TransportState.PAUSED_PLAYBACK;
+            case STOPPED -> TransportState.STOPPED;
+            case PREPARING -> TransportState.TRANSITIONING;
+            default -> TransportState.NO_MEDIA_PRESENT;
+        };
+    }
+
+    private long parseTimeToMs(String time) {
+        try {
+            String[] parts = time.split(":");
+            if (parts.length != 3) return 0;
+            int dot = parts[2].indexOf('.');
+            long secs = Long.parseLong(dot >= 0 ? parts[2].substring(0, dot) : parts[2]);
+            long frac = dot >= 0 ? Math.round(Double.parseDouble("0" + parts[2].substring(dot)) * 1000) : 0;
+            return (Long.parseLong(parts[0]) * 3600 + Long.parseLong(parts[1]) * 60 + secs) * 1000 + frac;
+        } catch (Exception ignored) {
+            return 0;
+        }
+    }
+
+    private String formatMs(long ms) {
+        if (ms <= 0) return "00:00:00";
+        long s = ms / 1000;
+        return String.format(Locale.US, "%02d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60);
+    }
+}

+ 58 - 0
app/src/leanback/java/com/fongmi/android/tv/dlna/DLNARenderingControlImpl.java

@@ -0,0 +1,58 @@
+package com.fongmi.android.tv.dlna;
+
+import android.content.Context;
+import android.media.AudioManager;
+
+import org.jupnp.model.types.UnsignedIntegerFourBytes;
+import org.jupnp.model.types.UnsignedIntegerTwoBytes;
+import org.jupnp.support.model.Channel;
+import org.jupnp.support.renderingcontrol.AbstractAudioRenderingControl;
+
+public class DLNARenderingControlImpl extends AbstractAudioRenderingControl {
+
+    private final Context context;
+
+    public DLNARenderingControlImpl(Context context) {
+        this.context = context;
+    }
+
+    private AudioManager getAudioManager() {
+        return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+    }
+
+    @Override
+    public UnsignedIntegerFourBytes[] getCurrentInstanceIds() {
+        return new UnsignedIntegerFourBytes[]{getDefaultInstanceID()};
+    }
+
+    @Override
+    public boolean getMute(UnsignedIntegerFourBytes instanceId, String channelName) {
+        return getAudioManager().isStreamMute(AudioManager.STREAM_MUSIC);
+    }
+
+    @Override
+    public void setMute(UnsignedIntegerFourBytes instanceId, String channelName, boolean desiredMute) {
+        getAudioManager().adjustStreamVolume(AudioManager.STREAM_MUSIC, desiredMute ? AudioManager.ADJUST_MUTE : AudioManager.ADJUST_UNMUTE, 0);
+    }
+
+    @Override
+    public UnsignedIntegerTwoBytes getVolume(UnsignedIntegerFourBytes instanceId, String channelName) {
+        AudioManager am = getAudioManager();
+        int current = am.getStreamVolume(AudioManager.STREAM_MUSIC);
+        int max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
+        return new UnsignedIntegerTwoBytes(max > 0 ? current * 100L / max : 100);
+    }
+
+    @Override
+    public void setVolume(UnsignedIntegerFourBytes instanceId, String channelName, UnsignedIntegerTwoBytes desiredVolume) {
+        AudioManager manager = getAudioManager();
+        int max = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
+        int volume = (int) (desiredVolume.getValue() * max / 100);
+        manager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0);
+    }
+
+    @Override
+    protected Channel[] getCurrentChannels() {
+        return new Channel[]{Channel.Master};
+    }
+}

+ 5 - 0
app/src/leanback/java/com/fongmi/android/tv/dlna/RenderState.java

@@ -0,0 +1,5 @@
+package com.fongmi.android.tv.dlna;
+
+public enum RenderState {
+    IDLE, PREPARING, PLAYING, PAUSED, STOPPED
+}

+ 44 - 0
app/src/leanback/java/com/fongmi/android/tv/receiver/BootReceiver.java

@@ -0,0 +1,44 @@
+package com.fongmi.android.tv.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.Network;
+
+import androidx.annotation.NonNull;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.api.config.LiveConfig;
+
+public class BootReceiver extends BroadcastReceiver {
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        registerCallback();
+    }
+
+    private void registerCallback() {
+        ((ConnectivityManager) App.get().getSystemService(Context.CONNECTIVITY_SERVICE)).registerDefaultNetworkCallback(new Callback());
+    }
+
+    static class Callback extends ConnectivityManager.NetworkCallback {
+
+        private boolean first;
+
+        @Override
+        public void onAvailable(@NonNull Network network) {
+            if (first) doJob();
+            else first = true;
+        }
+
+        @Override
+        public void onLost(@NonNull Network network) {
+        }
+
+        private void doJob() {
+            LiveConfig.get().init().load();
+            ((ConnectivityManager) App.get().getSystemService(Context.CONNECTIVITY_SERVICE)).unregisterNetworkCallback(this);
+        }
+    }
+}

+ 258 - 0
app/src/leanback/java/com/fongmi/android/tv/service/DLNARendererService.java

@@ -0,0 +1,258 @@
+package com.fongmi.android.tv.service;
+
+import android.app.Notification;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Build;
+import android.os.IBinder;
+
+import androidx.core.app.NotificationCompat;
+import androidx.media3.common.Player;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.dlna.CastAction;
+import com.fongmi.android.tv.dlna.DLNAAvTransportImpl;
+import com.fongmi.android.tv.dlna.DLNARenderingControlImpl;
+import com.fongmi.android.tv.dlna.DLNAServiceConfiguration;
+import com.fongmi.android.tv.dlna.RenderState;
+import com.fongmi.android.tv.player.PlayerManager;
+import com.fongmi.android.tv.utils.Notify;
+import com.fongmi.android.tv.utils.Util;
+
+import org.jupnp.UpnpServiceConfiguration;
+import org.jupnp.android.AndroidUpnpServiceImpl;
+import org.jupnp.binding.annotations.AnnotationLocalServiceBinder;
+import org.jupnp.model.DefaultServiceManager;
+import org.jupnp.model.meta.DeviceDetails;
+import org.jupnp.model.meta.DeviceIdentity;
+import org.jupnp.model.meta.LocalDevice;
+import org.jupnp.model.meta.LocalService;
+import org.jupnp.model.meta.ManufacturerDetails;
+import org.jupnp.model.meta.ModelDetails;
+import org.jupnp.model.types.UDADeviceType;
+import org.jupnp.model.types.UDN;
+import org.jupnp.support.avtransport.lastchange.AVTransportLastChangeParser;
+import org.jupnp.support.connectionmanager.ConnectionManagerService;
+import org.jupnp.support.lastchange.LastChangeAwareServiceManager;
+import org.jupnp.support.renderingcontrol.lastchange.RenderingControlLastChangeParser;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+public class DLNARendererService extends AndroidUpnpServiceImpl implements ServiceConnection {
+
+    private final IBinder binder = new LocalBinder();
+
+    private volatile PlayerManager player;
+    private volatile boolean isDlnaActive;
+
+    private DLNARenderingControlImpl renderingControlImpl;
+    private DLNAAvTransportImpl avTransportImpl;
+    private PlaybackService playbackService;
+    private Player currentListenerPlayer;
+    private boolean bound;
+
+    public static void start(Context context) {
+        context.startService(new Intent(context, DLNARendererService.class));
+    }
+
+    public static void stop(Context context) {
+        context.stopService(new Intent(context, DLNARendererService.class));
+    }
+
+    @Override
+    protected UpnpServiceConfiguration createConfiguration() {
+        return new DLNAServiceConfiguration();
+    }
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        Notification notification = new NotificationCompat.Builder(this, Notify.DEFAULT).setSmallIcon(R.drawable.ic_notification).setContentTitle(getString(R.string.app_name)).setSilent(true).build();
+        startForeground(Notify.ID + 1, notification);
+        upnpService.startup();
+        registerLocalDevice();
+    }
+
+    private void registerLocalDevice() {
+        LocalService<DLNAAvTransportImpl> avTransport = createAvTransport();
+        LocalService<ConnectionManagerService> connManager = createConnectionManager();
+        LocalService<DLNARenderingControlImpl> renderControl = createRenderingControl();
+        DeviceIdentity identity = new DeviceIdentity(new UDN(UUID.nameUUIDFromBytes((Build.MANUFACTURER + Build.MODEL + "-MediaRenderer").getBytes(StandardCharsets.UTF_8))));
+        UDADeviceType type = new UDADeviceType("MediaRenderer", 1);
+        DeviceDetails details = new DeviceDetails(Util.getDeviceName(), new ManufacturerDetails(Build.MANUFACTURER), new ModelDetails(Build.MODEL, "DLNA Renderer", "1.0"));
+        try {
+            LocalDevice device = new LocalDevice(identity, type, details, new LocalService[]{avTransport, connManager, renderControl});
+            upnpService.getRegistry().addDevice(device);
+        } catch (Exception ignored) {
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private LocalService<DLNAAvTransportImpl> createAvTransport() {
+        avTransportImpl = new DLNAAvTransportImpl(this);
+        LocalService<DLNAAvTransportImpl> service = new AnnotationLocalServiceBinder().read(DLNAAvTransportImpl.class);
+        service.setManager(new LastChangeAwareServiceManager<>(service, new AVTransportLastChangeParser()) {
+            @Override
+            protected DLNAAvTransportImpl createServiceInstance() {
+                return avTransportImpl;
+            }
+        });
+        return service;
+    }
+
+    @SuppressWarnings("unchecked")
+    private LocalService<ConnectionManagerService> createConnectionManager() {
+        LocalService<ConnectionManagerService> service = new AnnotationLocalServiceBinder().read(ConnectionManagerService.class);
+        service.setManager(new DefaultServiceManager<>(service, ConnectionManagerService.class));
+        return service;
+    }
+
+    @SuppressWarnings("unchecked")
+    private LocalService<DLNARenderingControlImpl> createRenderingControl() {
+        renderingControlImpl = new DLNARenderingControlImpl(this);
+        LocalService<DLNARenderingControlImpl> service = new AnnotationLocalServiceBinder().read(DLNARenderingControlImpl.class);
+        service.setManager(new LastChangeAwareServiceManager<>(service, new RenderingControlLastChangeParser()) {
+            @Override
+            protected DLNARenderingControlImpl createServiceInstance() {
+                return renderingControlImpl;
+            }
+        });
+        return service;
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return binder;
+    }
+
+    @Override
+    public void onDestroy() {
+        unbindPlaybackService();
+        super.onDestroy();
+    }
+
+    private void bindPlaybackService() {
+        if (bound) return;
+        bound = true;
+        bindService(new Intent(this, PlaybackService.class).setAction(PlaybackService.LOCAL_BIND_ACTION), this, BIND_AUTO_CREATE);
+    }
+
+    private void cleanupPlaybackRefs() {
+        App.removeCallbacks(positionUpdater);
+        if (currentListenerPlayer != null) {
+            currentListenerPlayer.removeListener(listener);
+            currentListenerPlayer = null;
+        }
+        if (playbackService != null) {
+            playbackService.removePlayerCallback(playerCallback);
+            playbackService = null;
+        }
+        player = null;
+        if (avTransportImpl != null) avTransportImpl.setPlayerManager(null);
+    }
+
+    private void unbindPlaybackService() {
+        if (!bound) return;
+        bound = false;
+        cleanupPlaybackRefs();
+        unbindService(this);
+    }
+
+    public void setDlnaActive(boolean active) {
+        isDlnaActive = active;
+        if (avTransportImpl != null) avTransportImpl.setDlnaActive(active);
+        if (active) bindPlaybackService();
+        else {
+            if (avTransportImpl != null) avTransportImpl.reset();
+            unbindPlaybackService();
+        }
+    }
+
+    public long consumePendingSeekMs() {
+        return avTransportImpl != null ? avTransportImpl.consumePendingSeekMs() : -1;
+    }
+
+    public CastAction consumeNext() {
+        return avTransportImpl != null ? avTransportImpl.popNext() : null;
+    }
+
+    public void notifyError() {
+        if (avTransportImpl != null) avTransportImpl.fireStateChange(RenderState.STOPPED);
+    }
+
+    @Override
+    public void onServiceConnected(ComponentName name, IBinder binder) {
+        if (!isDlnaActive) {
+            unbindPlaybackService();
+            return;
+        }
+        playbackService = ((PlaybackService.LocalBinder) binder).getService();
+        playbackService.addPlayerCallback(playerCallback);
+        player = playbackService.player();
+        avTransportImpl.setPlayerManager(player);
+        currentListenerPlayer = player.getPlayer();
+        currentListenerPlayer.addListener(listener);
+        App.post(positionUpdater, 1000);
+    }
+
+    @Override
+    public void onServiceDisconnected(ComponentName name) {
+        bound = false;
+        cleanupPlaybackRefs();
+    }
+
+    private void notifyState() {
+        if (avTransportImpl == null || player == null || !isDlnaActive) return;
+        int state = player.getPlaybackState();
+        if (state == Player.STATE_IDLE) return;
+        long duration = player.getDuration();
+        avTransportImpl.updatePositionCache(player.getPosition(), duration > 0 ? duration : -1);
+        RenderState renderState = switch (state) {
+            case Player.STATE_BUFFERING -> RenderState.PREPARING;
+            case Player.STATE_READY -> player.isPlaying() ? RenderState.PLAYING : RenderState.PAUSED;
+            case Player.STATE_ENDED -> avTransportImpl.hasNext() ? RenderState.PREPARING : RenderState.STOPPED;
+            default -> null;
+        };
+        if (renderState != null) avTransportImpl.fireStateChange(renderState);
+    }
+
+    private final Runnable positionUpdater = new Runnable() {
+        @Override
+        public void run() {
+            if (player != null && avTransportImpl != null && player.isPlaying()) avTransportImpl.updatePositionCache(player.getPosition(), player.getDuration());
+            if (player != null) App.post(this, 1000);
+        }
+    };
+
+    private final Player.Listener listener = new Player.Listener() {
+        @Override
+        public void onPlaybackStateChanged(int playbackState) {
+            notifyState();
+        }
+
+        @Override
+        public void onIsPlayingChanged(boolean isPlaying) {
+            notifyState();
+        }
+    };
+
+    private final PlaybackService.PlayerCallback playerCallback = new PlaybackService.PlayerCallback() {
+        @Override
+        public void onPlayerRebuild(Player newPlayer) {
+            if (currentListenerPlayer != null) currentListenerPlayer.removeListener(listener);
+            currentListenerPlayer = newPlayer;
+            newPlayer.addListener(listener);
+        }
+    };
+
+    public class LocalBinder extends Binder {
+
+        public DLNARendererService getService() {
+            return DLNARendererService.this;
+        }
+    }
+}

+ 521 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java

@@ -0,0 +1,521 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.annotation.SuppressLint;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.media3.common.C;
+import androidx.media3.common.MediaMetadata;
+import androidx.media3.common.Player;
+import androidx.media3.common.VideoSize;
+import androidx.media3.ui.PlayerView;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.Constant;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.bean.Sub;
+import com.fongmi.android.tv.databinding.ActivityCastBinding;
+import com.fongmi.android.tv.dlna.CastAction;
+import com.fongmi.android.tv.event.RefreshEvent;
+import com.fongmi.android.tv.player.PlayerHelper;
+import com.fongmi.android.tv.player.PlayerManager;
+import com.fongmi.android.tv.service.DLNARendererService;
+import com.fongmi.android.tv.service.PlaybackService;
+import com.fongmi.android.tv.ui.base.PlaybackActivity;
+import com.fongmi.android.tv.ui.custom.CustomKeyDownVod;
+import com.fongmi.android.tv.ui.custom.CustomSeekView;
+import com.fongmi.android.tv.ui.dialog.SubtitleDialog;
+import com.fongmi.android.tv.ui.dialog.TrackDialog;
+import com.fongmi.android.tv.utils.Clock;
+import com.fongmi.android.tv.utils.KeyUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.fongmi.android.tv.utils.Traffic;
+
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+import org.jupnp.support.contentdirectory.DIDLParser;
+
+public class CastActivity extends PlaybackActivity implements CustomKeyDownVod.Listener, TrackDialog.Listener {
+
+    private ActivityCastBinding mBinding;
+    private DLNARendererService mRenderer;
+    private CustomKeyDownVod mKeyDown;
+    private String mPlaybackKey;
+    private CastAction mAction;
+    private Runnable mR1;
+    private Runnable mR2;
+    private Clock mClock;
+    private long position;
+    private int scale;
+
+    @Override
+    protected boolean customWall() {
+        return false;
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivityCastBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected PlaybackService.NavigationCallback getNavigationCallback() {
+        return mNavigationCallback;
+    }
+
+    @Override
+    protected String getPlaybackKey() {
+        return mPlaybackKey;
+    }
+
+    @Override
+    protected PlayerView getExoView() {
+        return mBinding.exo;
+    }
+
+    @Override
+    protected CustomSeekView getSeekView() {
+        return mBinding.control.seek;
+    }
+
+    @Override
+    protected void onServiceConnected() {
+        mBinding.control.action.speed.setText(player().getSpeedText());
+        mBinding.control.action.decode.setText(player().getDecodeText());
+        setAction(getIntent());
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        setIntent(intent);
+        if (mRenderer != null) mRenderer.setDlnaActive(true);
+        if (intent.hasExtra(CastAction.KEY_EXTRA)) setAction(intent);
+        else finish();
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        super.initView(savedInstanceState);
+        bindService(new Intent(this, DLNARendererService.class), mRendererConnection, Context.BIND_AUTO_CREATE);
+        mClock = Clock.create(mBinding.widget.clock);
+        mKeyDown = CustomKeyDownVod.create(this);
+        mKeyDown.setFull(true);
+        mR1 = this::hideControl;
+        mR2 = this::setTraffic;
+        setVideoView();
+    }
+
+    @Override
+    @SuppressLint("ClickableViewAccessibility")
+    protected void initEvent() {
+        mBinding.control.action.speed.setUpListener(this::onSpeedAdd);
+        mBinding.control.action.speed.setDownListener(this::onSpeedSub);
+        mBinding.control.action.text.setUpListener(this::onSubtitleClick);
+        mBinding.control.action.text.setDownListener(this::onSubtitleClick);
+        mBinding.control.action.text.setOnClickListener(this::onTrack);
+        mBinding.control.action.audio.setOnClickListener(this::onTrack);
+        mBinding.control.action.video.setOnClickListener(this::onTrack);
+        mBinding.control.action.scale.setOnClickListener(view -> onScale());
+        mBinding.control.action.speed.setOnClickListener(view -> onSpeed());
+        mBinding.control.action.reset.setOnClickListener(view -> onReset());
+        mBinding.control.action.player.setOnClickListener(view -> onChoose());
+        mBinding.control.action.decode.setOnClickListener(view -> onDecode());
+        mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong());
+        mBinding.video.setOnTouchListener((view, event) -> mKeyDown.onTouchEvent(event));
+    }
+
+    private void setVideoView() {
+        setScale(scale = Setting.getScale());
+        findViewById(R.id.timeBar).setNextFocusUpId(R.id.reset);
+        mBinding.control.action.reset.setText(ResUtil.getStringArray(R.array.select_reset)[0]);
+    }
+
+    private String getName() {
+        try {
+            return new DIDLParser().parse(mAction.getCurrentURIMetaData()).getItems().get(0).getTitle();
+        } catch (Exception e) {
+            return mAction.getCurrentURI();
+        }
+    }
+
+    private void setAction(Intent intent) {
+        mAction = intent.getParcelableExtra(CastAction.KEY_EXTRA);
+        if (mAction == null) return;
+        mBinding.widget.title.setText(getName());
+        mBinding.widget.title.setSelected(true);
+        resetMedia();
+        start();
+    }
+
+    private void resetMedia() {
+        hideError();
+        hideControl();
+        player().setSpeed(1.0f);
+        player().setRepeatOne(false);
+    }
+
+    private void start() {
+        mPlaybackKey = mAction.getCurrentURI();
+        startPlayer(mPlaybackKey, mAction.result(), false, Constant.TIMEOUT_PLAY, buildMetadata());
+    }
+
+    private void setDecode() {
+        mBinding.control.action.decode.setText(player().getDecodeText());
+    }
+
+    private void setScale(int scale) {
+        mBinding.exo.setResizeMode(scale);
+        mBinding.control.action.scale.setText(ResUtil.getStringArray(R.array.select_scale)[scale]);
+    }
+
+    private void onScale() {
+        String[] array = ResUtil.getStringArray(R.array.select_scale);
+        scale = scale == array.length - 1 ? 0 : ++scale;
+        setScale(scale);
+    }
+
+    private void onSpeed() {
+        mBinding.control.action.speed.setText(player().addSpeed());
+    }
+
+    private void onSpeedAdd() {
+        mBinding.control.action.speed.setText(player().addSpeed(0.25f));
+    }
+
+    private void onSpeedSub() {
+        mBinding.control.action.speed.setText(player().subSpeed(0.25f));
+    }
+
+    private boolean onSpeedLong() {
+        mBinding.control.action.speed.setText(player().toggleSpeed());
+        return true;
+    }
+
+    private void onReset() {
+        if (player().isEmpty()) return;
+        position = player().getPosition();
+        start();
+    }
+
+    private void onChoose() {
+        PlayerHelper.choose(this, player().getUrl(), player().getHeaders(), player().isVod(), player().getPosition(), mBinding.widget.title.getText());
+        setRedirect(true);
+    }
+
+    private void onDecode() {
+        if (player().isEmpty()) return;
+        position = player().getPosition();
+        player().toggleDecode();
+        setDecode();
+    }
+
+    private void onTrack(View view) {
+        TrackDialog.create().type(Integer.parseInt(view.getTag().toString())).player(player()).show(this);
+        hideControl();
+    }
+
+    private void onToggle() {
+        if (isVisible(mBinding.control.getRoot())) hideControl();
+        else showControl();
+    }
+
+    private void showProgress() {
+        mBinding.progress.getRoot().setVisibility(View.VISIBLE);
+        App.post(mR2, 0);
+        hideCenter();
+        hideError();
+    }
+
+    private void hideProgress() {
+        mBinding.progress.getRoot().setVisibility(View.GONE);
+        App.removeCallbacks(mR2);
+        Traffic.reset();
+    }
+
+    private void showError(String text) {
+        mBinding.widget.error.setVisibility(View.VISIBLE);
+        mBinding.widget.text.setText(text);
+        hideProgress();
+    }
+
+    private void hideError() {
+        mBinding.widget.error.setVisibility(View.GONE);
+        mBinding.widget.text.setText("");
+    }
+
+    private void showInfo() {
+        mBinding.widget.top.setVisibility(View.VISIBLE);
+        mBinding.widget.center.setVisibility(View.VISIBLE);
+        mBinding.widget.duration.setText(player().getDurationTime());
+        mBinding.widget.position.setText(player().getPositionTime(0));
+    }
+
+    private void hideInfo() {
+        mBinding.widget.top.setVisibility(View.GONE);
+        mBinding.widget.center.setVisibility(View.GONE);
+    }
+
+    private void showControl() {
+        mBinding.control.getRoot().setVisibility(View.VISIBLE);
+        mBinding.control.action.reset.requestFocus();
+        setR1Callback();
+    }
+
+    private void hideControl() {
+        mBinding.control.getRoot().setVisibility(View.GONE);
+        App.removeCallbacks(mR1);
+    }
+
+    private void hideCenter() {
+        mBinding.widget.action.setImageResource(R.drawable.ic_widget_play);
+        hideInfo();
+    }
+
+    private void setTraffic() {
+        Traffic.setSpeed(mBinding.progress.traffic);
+        App.post(mR2, 1000);
+    }
+
+    private void setR1Callback() {
+        App.post(mR1, Constant.INTERVAL_HIDE);
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onRefreshEvent(RefreshEvent event) {
+        if (event.getType() == RefreshEvent.Type.PLAYER) onReset();
+        else if (event.getType() == RefreshEvent.Type.SUBTITLE) player().setSub(Sub.from(event.getPath()));
+    }
+
+    private void setTrackVisible() {
+        mBinding.control.action.text.setVisibility(player().haveTrack(C.TRACK_TYPE_TEXT) || player().isVod() ? View.VISIBLE : View.GONE);
+        mBinding.control.action.audio.setVisibility(player().haveTrack(C.TRACK_TYPE_AUDIO) ? View.VISIBLE : View.GONE);
+        mBinding.control.action.video.setVisibility(player().haveTrack(C.TRACK_TYPE_VIDEO) ? View.VISIBLE : View.GONE);
+    }
+
+    private MediaMetadata buildMetadata() {
+        return PlayerManager.buildMetadata(mBinding.widget.title.getText().toString(), "", "");
+    }
+
+    private void onPaused() {
+        controller().pause();
+    }
+
+    private void onPlay() {
+        if (isEnded()) controller().seekTo(0);
+        if (!player().isEmpty() && isIdle()) controller().prepare();
+        controller().play();
+    }
+
+    private void consumePendingSeek() {
+        if (service() == null || player().isEmpty() || mRenderer == null) return;
+        long seekMs = mRenderer.consumePendingSeekMs();
+        if (seekMs >= 0) player().seekTo(seekMs);
+    }
+
+    private final ServiceConnection mRendererConnection = new ServiceConnection() {
+        @Override
+        public void onServiceConnected(ComponentName name, IBinder binder) {
+            mRenderer = ((DLNARendererService.LocalBinder) binder).getService();
+            mRenderer.setDlnaActive(true);
+            consumePendingSeek();
+        }
+
+        @Override
+        public void onServiceDisconnected(ComponentName name) {
+            mRenderer = null;
+        }
+    };
+
+    private final PlaybackService.NavigationCallback mNavigationCallback = new PlaybackService.NavigationCallback() {
+        @Override
+        public void onStop() {
+            finish();
+        }
+    };
+
+    @Override
+    protected void onPrepare() {
+        setDecode();
+        setPosition();
+        consumePendingSeek();
+    }
+
+    @Override
+    protected void onTracksChanged() {
+        setTrackVisible();
+    }
+
+    @Override
+    protected void onError(String msg) {
+        if (mRenderer != null) mRenderer.notifyError();
+        player().resetTrack();
+        player().reset();
+        player().stop();
+        showError(msg);
+    }
+
+    @Override
+    protected void onStateChanged(int state) {
+        switch (state) {
+            case Player.STATE_BUFFERING:
+                showProgress();
+                break;
+            case Player.STATE_READY:
+                hideProgress();
+                player().reset();
+                break;
+            case Player.STATE_ENDED:
+                checkEnded();
+                break;
+        }
+    }
+
+    private void setPosition() {
+        if (position <= 0) return;
+        player().seekTo(position);
+        position = 0;
+    }
+
+    private void checkEnded() {
+        CastAction next = mRenderer != null ? mRenderer.consumeNext() : null;
+        if (next == null) return;
+        mAction = next;
+        mBinding.widget.title.setText(getName());
+        mBinding.widget.title.setSelected(true);
+        resetMedia();
+        start();
+    }
+
+    @Override
+    protected void onSizeChanged(VideoSize size) {
+        mBinding.widget.size.setText(player().getSizeText());
+    }
+
+    @Override
+    protected void onPlayingChanged(boolean isPlaying) {
+        if (isPlaying) {
+            hideCenter();
+        } else if (isPaused()) {
+            showInfo();
+        }
+    }
+
+    @Override
+    public void onSubtitleClick() {
+        SubtitleDialog.create().view(mBinding.exo.getSubtitleView()).full(true).show(this);
+        App.post(this::hideControl, 100);
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (KeyUtil.isMenuKey(event)) onToggle();
+        if (isVisible(mBinding.control.getRoot())) setR1Callback();
+        if (isGone(mBinding.control.getRoot()) && mKeyDown.hasEvent(event) && service() != null) return mKeyDown.onKeyDown(event);
+        return super.dispatchKeyEvent(event);
+    }
+
+    @Override
+    public void onSeeking(long time) {
+        if (player().isEmpty()) return;
+        mBinding.widget.center.setVisibility(View.VISIBLE);
+        mBinding.widget.duration.setText(player().getDurationTime());
+        mBinding.widget.position.setText(player().getPositionTime(time));
+        mBinding.widget.action.setImageResource(time > 0 ? R.drawable.ic_widget_forward : R.drawable.ic_widget_rewind);
+        hideProgress();
+    }
+
+    @Override
+    public void onSeekEnd(long time) {
+        if (player().isEmpty()) return;
+        mKeyDown.reset();
+        seekTo(time);
+    }
+
+    @Override
+    public void onSpeedUp() {
+        if (!player().isPlaying()) return;
+        mBinding.widget.speed.setVisibility(View.VISIBLE);
+        mBinding.widget.speed.startAnimation(ResUtil.getAnim(R.anim.forward));
+        mBinding.control.action.speed.setText(player().setSpeed(Setting.getSpeed()));
+    }
+
+    @Override
+    public void onSpeedEnd() {
+        mBinding.widget.speed.clearAnimation();
+        mBinding.widget.speed.setVisibility(View.GONE);
+        mBinding.control.action.speed.setText(player().setSpeed(1.0f));
+    }
+
+    @Override
+    public void onKeyUp() {
+        showControl();
+    }
+
+    @Override
+    public void onKeyDown() {
+        showControl();
+    }
+
+    @Override
+    public void onKeyCenter() {
+        if (player().isPlaying()) onPaused();
+        else onPlay();
+        hideControl();
+    }
+
+    @Override
+    public void onSingleTap() {
+        onToggle();
+    }
+
+    @Override
+    public void onDoubleTap() {
+        onKeyCenter();
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        mClock.stop().start();
+    }
+
+    @Override
+    protected void onStop() {
+        super.onStop();
+        if (Setting.isBackgroundOff()) mClock.stop();
+    }
+
+    @Override
+    protected void onBackInvoked() {
+        if (isVisible(mBinding.control.getRoot())) {
+            hideControl();
+        } else if (isVisible(mBinding.widget.center)) {
+            hideCenter();
+        } else {
+            super.onBackInvoked();
+        }
+    }
+
+    private void releaseRenderer() {
+        if (mRenderer == null) return;
+        mRenderer.setDlnaActive(false);
+        unbindService(mRendererConnection);
+    }
+
+    @Override
+    protected void onDestroy() {
+        mClock.release();
+        releaseRenderer();
+        App.removeCallbacks(mR1, mR2);
+        super.onDestroy();
+    }
+}

+ 192 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java

@@ -0,0 +1,192 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.leanback.widget.OnChildViewHolderSelectedListener;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewbinding.ViewBinding;
+import androidx.viewpager.widget.ViewPager;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.bean.Collect;
+import com.fongmi.android.tv.bean.Site;
+import com.fongmi.android.tv.databinding.ActivityCollectBinding;
+import com.fongmi.android.tv.model.SiteViewModel;
+import com.fongmi.android.tv.ui.adapter.CollectAdapter;
+import com.fongmi.android.tv.ui.base.BaseActivity;
+import com.fongmi.android.tv.ui.fragment.CollectFragment;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.gson.reflect.TypeToken;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CollectActivity extends BaseActivity {
+
+    private ActivityCollectBinding mBinding;
+    private CollectAdapter mAdapter;
+    private SiteViewModel mViewModel;
+    private List<Site> mSites;
+    private View mOldView;
+
+    public static void start(Activity activity, String keyword) {
+        Intent intent = new Intent(activity, CollectActivity.class);
+        intent.putExtra("keyword", keyword);
+        activity.startActivity(intent);
+    }
+
+    private CollectFragment getFragment() {
+        return (CollectFragment) mBinding.pager.getAdapter().instantiateItem(mBinding.pager, 0);
+    }
+
+    private String getKeyword() {
+        return getIntent().getStringExtra("keyword");
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivityCollectBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        getIntent().putExtras(intent);
+        mAdapter.clear();
+        setPager();
+        search();
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        setRecyclerView();
+        setViewModel();
+        saveKeyword();
+        setSites();
+        setPager();
+        search();
+    }
+
+    @Override
+    protected void initEvent() {
+        mBinding.pager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+            @Override
+            public void onPageSelected(int position) {
+                mBinding.recycler.setSelectedPosition(position);
+                mBinding.recycler.requestFocus();
+            }
+        });
+        mBinding.recycler.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() {
+            @Override
+            public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) {
+                onChildSelected(child);
+            }
+        });
+    }
+
+    private void setRecyclerView() {
+        mBinding.recycler.setHorizontalSpacing(ResUtil.dp2px(16));
+        mBinding.recycler.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+        mBinding.recycler.setAdapter(mAdapter = new CollectAdapter());
+    }
+
+    private void setViewModel() {
+        mViewModel = new ViewModelProvider(this).get(SiteViewModel.class);
+        mViewModel.getSearch().observe(this, result -> {
+            if (result.getList().isEmpty()) return;
+            getFragment().addVideo(result.getList());
+            mAdapter.add(Collect.create(result.getList()));
+            mBinding.pager.getAdapter().notifyDataSetChanged();
+        });
+    }
+
+    private void saveKeyword() {
+        List<String> items = Setting.getKeyword().isEmpty() ? new ArrayList<>() : App.gson().fromJson(Setting.getKeyword(), new TypeToken<List<String>>() {}.getType());
+        items.remove(getKeyword());
+        items.add(0, getKeyword());
+        if (items.size() > 9) items.remove(9);
+        Setting.putKeyword(App.gson().toJson(items));
+    }
+
+    private void setSites() {
+        mSites = VodConfig.get().getSites().stream().filter(Site::isSearchable).toList();
+    }
+
+    private void setPager() {
+        mBinding.pager.setAdapter(new PageAdapter(getSupportFragmentManager()));
+    }
+
+    private void search() {
+        if (mSites.isEmpty()) return;
+        mAdapter.add(Collect.all());
+        mBinding.pager.getAdapter().notifyDataSetChanged();
+        mBinding.result.setText(getString(R.string.collect_result, getKeyword()));
+        mViewModel.searchContent(mSites, getKeyword(), false);
+    }
+
+    private void onChildSelected(@Nullable RecyclerView.ViewHolder child) {
+        if (mOldView != null) mOldView.setActivated(false);
+        if ((mOldView = child != null ? child.itemView : null) == null) return;
+        mOldView.setActivated(true);
+        App.post(mRunnable, 100);
+    }
+
+    private final Runnable mRunnable = new Runnable() {
+        @Override
+        public void run() {
+            mBinding.pager.setCurrentItem(mBinding.recycler.getSelectedPosition());
+        }
+    };
+
+    @Override
+    protected void onBackInvoked() {
+        mViewModel.stopSearch();
+        super.onBackInvoked();
+    }
+
+    class PageAdapter extends FragmentStatePagerAdapter {
+
+        public PageAdapter(@NonNull FragmentManager fm) {
+            super(fm);
+        }
+
+        @NonNull
+        @Override
+        public Fragment getItem(int position) {
+            return CollectFragment.newInstance(getKeyword(), mAdapter.get(position));
+        }
+
+        @Override
+        public int getCount() {
+            return mAdapter.getItemCount();
+        }
+
+        @Override
+        public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
+        }
+
+        @Nullable
+        @Override
+        public Parcelable saveState() {
+            return null;
+        }
+
+        @Override
+        public void restoreState(@Nullable Parcelable state, @Nullable ClassLoader loader) {
+        }
+    }
+}

+ 73 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java

@@ -0,0 +1,73 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.databinding.ActivityFileBinding;
+import com.fongmi.android.tv.ui.adapter.FileAdapter;
+import com.fongmi.android.tv.ui.base.BaseActivity;
+import com.fongmi.android.tv.utils.PermissionUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.github.catvod.utils.Path;
+
+import java.io.File;
+
+public class FileActivity extends BaseActivity implements FileAdapter.OnClickListener {
+
+    private ActivityFileBinding mBinding;
+    private FileAdapter mAdapter;
+    private File dir;
+
+    private boolean isRoot() {
+        return Path.root().equals(dir);
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivityFileBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        setRecyclerView();
+        checkPermission();
+    }
+
+    private void setRecyclerView() {
+        mBinding.recycler.setHasFixedSize(true);
+        mBinding.recycler.setVerticalSpacing(ResUtil.dp2px(16));
+        mBinding.recycler.setAdapter(mAdapter = new FileAdapter(this));
+    }
+
+    private void checkPermission() {
+        PermissionUtil.requestFile(this, allGranted -> update(Path.root()));
+    }
+
+    private void update(File dir) {
+        mBinding.recycler.setSelectedPosition(0);
+        mAdapter.addAll(Path.list(this.dir = dir));
+        mBinding.progressLayout.showContent(true, mAdapter.getItemCount());
+    }
+
+    @Override
+    public void onItemClick(File file) {
+        if (file.isDirectory()) {
+            update(file);
+        } else {
+            setResult(RESULT_OK, new Intent().setData(Uri.fromFile(file)));
+            finish();
+        }
+    }
+
+    @Override
+    protected void onBackInvoked() {
+        if (isRoot()) {
+            super.onBackInvoked();
+        } else {
+            update(dir.getParentFile());
+        }
+    }
+}

+ 484 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java

@@ -0,0 +1,484 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.annotation.SuppressLint;
+import android.app.SearchManager;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.splashscreen.SplashScreen;
+import androidx.leanback.widget.ArrayObjectAdapter;
+import androidx.leanback.widget.FocusHighlight;
+import androidx.leanback.widget.HorizontalGridView;
+import androidx.leanback.widget.ItemBridgeAdapter;
+import androidx.leanback.widget.ListRow;
+import androidx.leanback.widget.OnChildViewHolderSelectedListener;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.Product;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.api.config.LiveConfig;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.api.config.WallConfig;
+import com.fongmi.android.tv.bean.Cache;
+import com.fongmi.android.tv.bean.Config;
+import com.fongmi.android.tv.bean.Func;
+import com.fongmi.android.tv.bean.History;
+import com.fongmi.android.tv.bean.Result;
+import com.fongmi.android.tv.bean.Site;
+import com.fongmi.android.tv.bean.Style;
+import com.fongmi.android.tv.bean.Vod;
+import com.fongmi.android.tv.databinding.ActivityHomeBinding;
+import com.fongmi.android.tv.db.AppDatabase;
+import com.fongmi.android.tv.event.CastEvent;
+import com.fongmi.android.tv.event.ConfigEvent;
+import com.fongmi.android.tv.event.RefreshEvent;
+import com.fongmi.android.tv.event.ServerEvent;
+import com.fongmi.android.tv.impl.Callback;
+import com.fongmi.android.tv.model.SiteViewModel;
+import com.fongmi.android.tv.player.Source;
+import com.fongmi.android.tv.server.Server;
+import com.fongmi.android.tv.service.DLNARendererService;
+import com.fongmi.android.tv.service.PlaybackService;
+import com.fongmi.android.tv.ui.adapter.BaseDiffCallback;
+import com.fongmi.android.tv.ui.base.BaseActivity;
+import com.fongmi.android.tv.ui.custom.CustomRowPresenter;
+import com.fongmi.android.tv.ui.custom.CustomSelector;
+import com.fongmi.android.tv.ui.custom.CustomTitleView;
+import com.fongmi.android.tv.ui.dialog.SiteDialog;
+import com.fongmi.android.tv.ui.presenter.FuncPresenter;
+import com.fongmi.android.tv.ui.presenter.HeaderPresenter;
+import com.fongmi.android.tv.ui.presenter.HistoryPresenter;
+import com.fongmi.android.tv.ui.presenter.ProgressPresenter;
+import com.fongmi.android.tv.ui.presenter.VodPresenter;
+import com.fongmi.android.tv.utils.Clock;
+import com.fongmi.android.tv.utils.FileChooser;
+import com.fongmi.android.tv.utils.ImgUtil;
+import com.fongmi.android.tv.utils.KeyUtil;
+import com.fongmi.android.tv.utils.Notify;
+import com.fongmi.android.tv.utils.PermissionUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.fongmi.android.tv.utils.UrlUtil;
+import com.github.catvod.net.OkHttp;
+import com.google.common.collect.Lists;
+
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+public class HomeActivity extends BaseActivity implements CustomTitleView.Listener, VodPresenter.OnClickListener, FuncPresenter.OnClickListener, HistoryPresenter.OnClickListener {
+
+    private ActivityHomeBinding mBinding;
+    private ArrayObjectAdapter mHistoryAdapter;
+    private ArrayObjectAdapter mFuncAdapter;
+    private ArrayObjectAdapter mAdapter;
+    private HistoryPresenter mPresenter;
+    private SiteViewModel mViewModel;
+    private Result mResult;
+    private Clock mClock;
+
+    private Site getHome() {
+        return VodConfig.get().getHome();
+    }
+
+    private Config getConfig() {
+        return VodConfig.get().getConfig();
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivityHomeBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        checkAction(intent);
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        SplashScreen.installSplashScreen(this);
+        super.onCreate(savedInstanceState);
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        mResult = Result.empty();
+        mClock = Clock.create(mBinding.clock);
+        mBinding.progressLayout.showProgress();
+        DLNARendererService.start(this);
+        setRecyclerView();
+        setViewModel();
+        setAdapter();
+        initConfig();
+        setTitle();
+        setLogo();
+    }
+
+    @Override
+    protected void initEvent() {
+        mBinding.title.setListener(this);
+        mBinding.recycler.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() {
+            @Override
+            public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) {
+                mBinding.toolbar.setVisibility(position == 0 ? View.VISIBLE : View.GONE);
+                if (mPresenter.isDelete()) setHistoryDelete(false);
+            }
+        });
+    }
+
+    private void checkAction(Intent intent) {
+        if (Intent.ACTION_SEND.equals(intent.getAction())) {
+            VideoActivity.push(this, intent.getStringExtra(Intent.EXTRA_TEXT));
+        } else if (Intent.ACTION_VIEW.equals(intent.getAction()) && intent.getData() != null) {
+            PermissionUtil.requestFile(this, allGranted -> checkType(intent));
+        } else if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
+            String keyword = intent.getStringExtra(SearchManager.QUERY);
+            if (!TextUtils.isEmpty(keyword)) SearchActivity.start(this, keyword);
+        }
+    }
+
+    private void checkType(Intent intent) {
+        if ("text/plain".equals(intent.getType()) || UrlUtil.path(intent.getData()).endsWith(".m3u")) {
+            loadLive("file:/" + FileChooser.getPathFromUri(intent.getData()));
+        } else {
+            VideoActivity.push(this, intent.getData().toString());
+        }
+    }
+
+    @SuppressLint("RestrictedApi")
+    private void setRecyclerView() {
+        CustomSelector selector = new CustomSelector();
+        selector.addPresenter(Integer.class, new HeaderPresenter());
+        selector.addPresenter(String.class, new ProgressPresenter());
+        selector.addPresenter(Vod.class, new VodPresenter(this, Style.list()));
+        selector.addPresenter(ListRow.class, new CustomRowPresenter(16), VodPresenter.class);
+        selector.addPresenter(ListRow.class, new CustomRowPresenter(16), FuncPresenter.class);
+        selector.addPresenter(ListRow.class, new CustomRowPresenter(16, FocusHighlight.ZOOM_FACTOR_SMALL, HorizontalGridView.FOCUS_SCROLL_ALIGNED), HistoryPresenter.class);
+        mBinding.recycler.setAdapter(new ItemBridgeAdapter(mAdapter = new ArrayObjectAdapter(selector)));
+        mBinding.recycler.setVerticalSpacing(ResUtil.dp2px(16));
+    }
+
+    private void setViewModel() {
+        mViewModel = new ViewModelProvider(this).get(SiteViewModel.class);
+        mViewModel.getResult().observe(this, result -> {
+            mAdapter.remove("progress");
+            addVideo(mResult = result);
+            Cache.clear().put(result);
+        });
+    }
+
+    private void setAdapter() {
+        mHistoryAdapter = new ArrayObjectAdapter(mPresenter = new HistoryPresenter(this));
+        mAdapter.add(new ListRow(mFuncAdapter = new ArrayObjectAdapter(new FuncPresenter(this))));
+        mAdapter.add(R.string.home_history);
+        mAdapter.add(R.string.home_recommend);
+    }
+
+    private void setTitle() {
+        List<String> items = Arrays.asList(getHome().getName(), getConfig().getName(), getString(R.string.app_name));
+        Optional<String> optional = items.stream().filter(s -> !TextUtils.isEmpty(s)).findFirst();
+        optional.ifPresent(s -> mBinding.title.setText(s));
+    }
+
+    private void initConfig() {
+        VodConfig.get().init().load(getCallback());
+        LiveConfig.get().init().load();
+        WallConfig.get().init();
+    }
+
+    private Callback getCallback() {
+        return new Callback() {
+            @Override
+            public void success() {
+                showContent();
+            }
+
+            @Override
+            public void error(String msg) {
+                Notify.show(msg);
+                showContent();
+            }
+        };
+    }
+
+    private void showContent() {
+        mBinding.progressLayout.showContent();
+        checkAction(getIntent());
+        setFocus();
+    }
+
+    private void loadLive(String url) {
+        LiveConfig.load(Config.find(url, 1), new Callback() {
+            @Override
+            public void success() {
+                LiveActivity.start(getActivity());
+            }
+        });
+    }
+
+    private void setFocus() {
+        mBinding.title.setSelected(true);
+        App.post(() -> mBinding.title.setFocusable(true), 500);
+        if (!mBinding.title.hasFocus()) mBinding.recycler.requestFocus();
+    }
+
+    private void getVideo() {
+        mResult = Result.empty();
+        int index = getRecommendIndex();
+        boolean gone = mAdapter.indexOf("progress") == -1;
+        boolean hasItem = gone && mAdapter.size() > index;
+        if (hasItem) mAdapter.removeItems(index, mAdapter.size() - index);
+        if (gone) mAdapter.add("progress");
+        mViewModel.homeContent();
+    }
+
+    private void addVideo(Result result) {
+        Style style = result.getStyle(getHome().getStyle());
+        if (style.isList()) mAdapter.addAll(mAdapter.size(), result.getList());
+        else addGrid(result.getList(), style);
+    }
+
+    private void addGrid(List<Vod> items, Style style) {
+        List<ListRow> rows = new ArrayList<>();
+        VodPresenter presenter = new VodPresenter(this, style);
+        for (List<Vod> part : Lists.partition(items, Product.getColumn(style))) {
+            ArrayObjectAdapter adapter = new ArrayObjectAdapter(presenter);
+            adapter.addAll(0, part);
+            rows.add(new ListRow(adapter));
+        }
+        mAdapter.addAll(mAdapter.size(), rows);
+    }
+
+    private void setFunc() {
+        List<Func> items = new ArrayList<>();
+        if (!TextUtils.isEmpty(VodConfig.getUrl())) items.add(Func.create(R.string.home_vod));
+        items.add(Func.create(R.string.home_live));
+        items.add(Func.create(R.string.home_setting));
+        mFuncAdapter.setItems(items, new BaseDiffCallback<Func>());
+    }
+
+    private void getHistory() {
+        getHistory(false);
+    }
+
+    private void getHistory(boolean renew) {
+        List<History> items = History.get();
+        int historyIndex = getHistoryIndex();
+        int recommendIndex = getRecommendIndex();
+        boolean exist = recommendIndex - historyIndex == 2;
+        if (renew) mHistoryAdapter = new ArrayObjectAdapter(mPresenter = new HistoryPresenter(this));
+        if ((items.isEmpty() && exist) || (renew && exist)) mAdapter.removeItems(historyIndex, 1);
+        if ((!items.isEmpty() && !exist) || (renew && exist)) mAdapter.add(historyIndex, new ListRow(mHistoryAdapter));
+        mHistoryAdapter.setItems(items, new BaseDiffCallback<History>());
+    }
+
+    private void setHistoryDelete(boolean delete) {
+        mPresenter.setDelete(delete);
+        mHistoryAdapter.notifyArrayItemRangeChanged(0, mHistoryAdapter.size());
+    }
+
+    private void clearHistory() {
+        mAdapter.removeItems(getHistoryIndex(), 1);
+        History.delete(VodConfig.getCid());
+        mPresenter.setDelete(false);
+        mHistoryAdapter.clear();
+    }
+
+    private int getHistoryIndex() {
+        return mAdapter.indexOf(R.string.home_history) + 1;
+    }
+
+    private int getRecommendIndex() {
+        return mAdapter.indexOf(R.string.home_recommend) + 1;
+    }
+
+    private void setLogo() {
+        ImgUtil.logo(mBinding.logo);
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onConfigEvent(ConfigEvent event) {
+        switch (event.type()) {
+            case VOD:
+                RefreshEvent.history();
+                RefreshEvent.home();
+                setLogo();
+                break;
+            case COMMON:
+                setFunc();
+                break;
+            case BOOT:
+                LiveActivity.start(this);
+                break;
+        }
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onRefreshEvent(RefreshEvent event) {
+        switch (event.getType()) {
+            case HOME:
+                getVideo();
+                setTitle();
+                break;
+            case HISTORY:
+                getHistory();
+                break;
+            case SIZE:
+                getVideo();
+                getHistory(true);
+                break;
+        }
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onServerEvent(ServerEvent event) {
+        switch (event.type()) {
+            case SEARCH:
+                SearchActivity.start(this, event.text());
+                break;
+            case PUSH:
+                VideoActivity.push(this, event.text());
+                break;
+        }
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onCastEvent(CastEvent event) {
+        if (VodConfig.get().getConfig().equals(event.config())) {
+            VideoActivity.cast(this, event.history().save(VodConfig.getCid()));
+        } else {
+            VodConfig.load(event.config(), getCallback(event));
+        }
+    }
+
+    private Callback getCallback(CastEvent event) {
+        return new Callback() {
+            @Override
+            public void success() {
+                onCastEvent(event);
+            }
+
+            @Override
+            public void error(String msg) {
+                Notify.show(msg);
+            }
+        };
+    }
+
+    @Override
+    public void onItemClick(Func item) {
+        if (item.getResId() == R.string.home_vod) VodActivity.start(this, mResult);
+        else if (item.getResId() == R.string.home_live) LiveActivity.start(this);
+        else if (item.getResId() == R.string.home_keep) KeepActivity.start(this);
+        else if (item.getResId() == R.string.home_push) PushActivity.start(this);
+        else if (item.getResId() == R.string.home_search) SearchActivity.start(this);
+        else if (item.getResId() == R.string.home_setting) SettingActivity.start(this);
+    }
+
+    @Override
+    public void onItemClick(Vod item) {
+        if (item.isAction()) mViewModel.action(getHome().getKey(), item.getAction());
+        else if (getHome().isIndex()) CollectActivity.start(this, item.getName());
+        else VideoActivity.start(this, getHome().getKey(), item.getId(), item.getName(), item.getPic());
+    }
+
+    @Override
+    public boolean onLongClick(Vod item) {
+        if (item.isAction()) return false;
+        CollectActivity.start(this, item.getName());
+        return true;
+    }
+
+    @Override
+    public void onItemClick(History item) {
+        VideoActivity.start(this, item.getSiteKey(), item.getVodId(), item.getVodName(), item.getVodPic());
+    }
+
+    @Override
+    public void onItemDelete(History item) {
+        mHistoryAdapter.remove(item.delete());
+        if (mHistoryAdapter.size() > 0) return;
+        mAdapter.removeItems(getHistoryIndex(), 1);
+        mPresenter.setDelete(false);
+    }
+
+    @Override
+    public boolean onLongClick() {
+        if (mPresenter.isDelete()) clearHistory();
+        else setHistoryDelete(true);
+        return true;
+    }
+
+    @Override
+    public void showDialog() {
+        SiteDialog.create(this).show();
+    }
+
+    @Override
+    public void onRefresh() {
+        getVideo();
+    }
+
+    @Override
+    public void setSite(Site item) {
+        VodConfig.get().setHome(item);
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (KeyUtil.isMenuKey(event)) showDialog();
+        if (KeyUtil.isActionDown(event) & KeyUtil.isDownKey(event) && getCurrentFocus() == mBinding.title) return mBinding.recycler.getChildAt(0).requestFocus();
+        return super.dispatchKeyEvent(event);
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        mClock.start();
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+        mClock.stop();
+    }
+
+    @Override
+    protected void onBackInvoked() {
+        if (mBinding.progressLayout.isProgress()) {
+            showContent();
+        } else if (mPresenter.isDelete()) {
+            setHistoryDelete(false);
+        } else if (mBinding.recycler.getSelectedPosition() != 0) {
+            mBinding.recycler.scrollToPosition(0);
+        } else {
+            if (PlaybackService.isRunning()) moveTaskToBack(true);
+            else super.onBackInvoked();
+        }
+    }
+
+    @Override
+    protected void onDestroy() {
+        DLNARendererService.stop(this);
+        LiveConfig.get().clear();
+        VodConfig.get().clear();
+        AppDatabase.backup();
+        OkHttp.get().clear();
+        Source.get().exit();
+        Server.get().stop();
+        super.onDestroy();
+    }
+}

+ 102 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java

@@ -0,0 +1,102 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.Product;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.bean.Config;
+import com.fongmi.android.tv.bean.Keep;
+import com.fongmi.android.tv.databinding.ActivityKeepBinding;
+import com.fongmi.android.tv.event.RefreshEvent;
+import com.fongmi.android.tv.impl.Callback;
+import com.fongmi.android.tv.ui.adapter.KeepAdapter;
+import com.fongmi.android.tv.ui.base.BaseActivity;
+import com.fongmi.android.tv.ui.custom.SpaceItemDecoration;
+import com.fongmi.android.tv.utils.Notify;
+
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickListener {
+
+    private ActivityKeepBinding mBinding;
+    private KeepAdapter mAdapter;
+
+    public static void start(Activity activity) {
+        activity.startActivity(new Intent(activity, KeepActivity.class));
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivityKeepBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        setRecyclerView();
+        getKeep();
+    }
+
+    private void setRecyclerView() {
+        mBinding.recycler.setHasFixedSize(true);
+        mBinding.recycler.setItemAnimator(null);
+        mBinding.recycler.setAdapter(mAdapter = new KeepAdapter(this));
+        mBinding.recycler.setLayoutManager(new GridLayoutManager(this, Product.getColumn()));
+        mBinding.recycler.addItemDecoration(new SpaceItemDecoration(Product.getColumn(), 16));
+    }
+
+    private void getKeep() {
+        mAdapter.setItems(Keep.getVod(), () -> mBinding.progressLayout.showContent(true, mAdapter.getItemCount()));
+    }
+
+    private void loadConfig(Config config, Keep item) {
+        VodConfig.load(config, new Callback() {
+            @Override
+            public void success() {
+                VideoActivity.start(getActivity(), item.getSiteKey(), item.getVodId(), item.getVodName(), item.getVodPic());
+            }
+
+            @Override
+            public void error(String msg) {
+                Notify.show(msg);
+            }
+        });
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onRefreshEvent(RefreshEvent event) {
+        if (event.getType() == RefreshEvent.Type.KEEP) getKeep();
+    }
+
+    @Override
+    public void onItemClick(Keep item) {
+        Config config = Config.find(item.getCid());
+        if (config == null) CollectActivity.start(this, item.getVodName());
+        else if (item.getCid() != VodConfig.getCid()) loadConfig(config, item);
+        else VideoActivity.start(this, item.getSiteKey(), item.getVodId(), item.getVodName(), item.getVodPic());
+    }
+
+    @Override
+    public void onItemDelete(Keep item) {
+        mAdapter.remove(item.delete(), () -> {
+            if (mAdapter.getItemCount() == 0) mAdapter.setDelete(false);
+        });
+    }
+
+    @Override
+    public boolean onLongClick() {
+        mAdapter.setDelete(true);
+        return true;
+    }
+
+    @Override
+    protected void onBackInvoked() {
+        if (mAdapter.isDelete()) mAdapter.setDelete(false);
+        else super.onBackInvoked();
+    }
+}

+ 1069 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java

@@ -0,0 +1,1069 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.leanback.widget.OnChildViewHolderSelectedListener;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.media3.common.C;
+import androidx.media3.common.MediaMetadata;
+import androidx.media3.common.Player;
+import androidx.media3.common.VideoSize;
+import androidx.media3.ui.PlayerView;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewbinding.ViewBinding;
+
+import com.bumptech.glide.request.transition.Transition;
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.Constant;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.api.config.LiveConfig;
+import com.fongmi.android.tv.bean.Channel;
+import com.fongmi.android.tv.bean.Config;
+import com.fongmi.android.tv.bean.Epg;
+import com.fongmi.android.tv.bean.EpgData;
+import com.fongmi.android.tv.bean.Group;
+import com.fongmi.android.tv.bean.Keep;
+import com.fongmi.android.tv.bean.Live;
+import com.fongmi.android.tv.bean.Result;
+import com.fongmi.android.tv.bean.Track;
+import com.fongmi.android.tv.databinding.ActivityLiveBinding;
+import com.fongmi.android.tv.event.RefreshEvent;
+import com.fongmi.android.tv.impl.Callback;
+import com.fongmi.android.tv.impl.ConfigCallback;
+import com.fongmi.android.tv.impl.CustomTarget;
+import com.fongmi.android.tv.impl.LiveCallback;
+import com.fongmi.android.tv.impl.PassCallback;
+import com.fongmi.android.tv.model.LiveViewModel;
+import com.fongmi.android.tv.player.PlayerHelper;
+import com.fongmi.android.tv.player.PlayerManager;
+import com.fongmi.android.tv.player.Source;
+import com.fongmi.android.tv.service.PlaybackService;
+import com.fongmi.android.tv.ui.adapter.ChannelAdapter;
+import com.fongmi.android.tv.ui.adapter.EpgDataAdapter;
+import com.fongmi.android.tv.ui.adapter.GroupAdapter;
+import com.fongmi.android.tv.ui.base.PlaybackActivity;
+import com.fongmi.android.tv.ui.custom.CustomKeyDownLive;
+import com.fongmi.android.tv.ui.custom.CustomLiveListView;
+import com.fongmi.android.tv.ui.custom.CustomSeekView;
+import com.fongmi.android.tv.ui.dialog.HistoryDialog;
+import com.fongmi.android.tv.ui.dialog.LiveDialog;
+import com.fongmi.android.tv.ui.dialog.PassDialog;
+import com.fongmi.android.tv.ui.dialog.SubtitleDialog;
+import com.fongmi.android.tv.ui.dialog.TrackDialog;
+import com.fongmi.android.tv.utils.Clock;
+import com.fongmi.android.tv.utils.ImgUtil;
+import com.fongmi.android.tv.utils.Notify;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.fongmi.android.tv.utils.Traffic;
+
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnClickListener, ChannelAdapter.OnClickListener, EpgDataAdapter.OnClickListener, CustomKeyDownLive.Listener, CustomLiveListView.Callback, TrackDialog.Listener, PassCallback, ConfigCallback, LiveCallback {
+
+    private ActivityLiveBinding mBinding;
+    private ChannelAdapter mChannelAdapter;
+    private EpgDataAdapter mEpgDataAdapter;
+    private GroupAdapter mGroupAdapter;
+    private Observer<Result> mObserveUrl;
+    private CustomKeyDownLive mKeyDown;
+    private Observer<Epg> mObserveEpg;
+    private LiveViewModel mViewModel;
+    private List<Group> mHides;
+    private String mPlaybackKey;
+    private Channel mChannel;
+    private View mOldView;
+    private Group mGroup;
+    private Runnable mR0;
+    private Runnable mR1;
+    private Runnable mR2;
+    private Runnable mR3;
+    private Runnable mR4;
+    private Clock mClock;
+    private View mFocus2;
+    private int count;
+
+    public static void start(Context context) {
+        context.startActivity(new Intent(context, LiveActivity.class).putExtra("empty", LiveConfig.isEmpty()));
+    }
+
+    private boolean isEmpty() {
+        return getIntent().getBooleanExtra("empty", true);
+    }
+
+    private Group getKeep() {
+        return mGroupAdapter.get(0);
+    }
+
+    private Live getHome() {
+        return LiveConfig.get().getHome();
+    }
+
+    @Override
+    protected boolean customWall() {
+        return false;
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivityLiveBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected PlaybackService.NavigationCallback getNavigationCallback() {
+        return mNavigationCallback;
+    }
+
+    @Override
+    protected String getPlaybackKey() {
+        return mPlaybackKey;
+    }
+
+    @Override
+    protected PlayerView getExoView() {
+        return mBinding.exo;
+    }
+
+    @Override
+    protected CustomSeekView getSeekView() {
+        return mBinding.control.seek;
+    }
+
+    @Override
+    protected void onServiceConnected() {
+        mBinding.control.action.speed.setText(player().getSpeedText());
+        mBinding.control.action.decode.setText(player().getDecodeText());
+        checkLive();
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        super.initView(savedInstanceState);
+        mClock = Clock.create(mBinding.widget.clock);
+        mKeyDown = CustomKeyDownLive.create(this);
+        mObserveEpg = this::setEpg;
+        mObserveUrl = this::start;
+        mHides = new ArrayList<>();
+        mR0 = this::setActivated;
+        mR1 = this::hideControl;
+        mR2 = this::setTraffic;
+        mR3 = this::hideInfo;
+        mR4 = this::hideUI;
+        setRecyclerView();
+        setVideoView();
+        setViewModel();
+    }
+
+    @Override
+    @SuppressLint("ClickableViewAccessibility")
+    protected void initEvent() {
+        mBinding.group.setListener(this);
+        mBinding.channel.setListener(this);
+        mBinding.epgData.setListener(this);
+        mBinding.control.action.text.setOnClickListener(this::onTrack);
+        mBinding.control.action.audio.setOnClickListener(this::onTrack);
+        mBinding.control.action.video.setOnClickListener(this::onTrack);
+        mBinding.control.action.speed.setUpListener(this::onSpeedAdd);
+        mBinding.control.action.speed.setDownListener(this::onSpeedSub);
+        mBinding.control.action.text.setUpListener(this::onSubtitleClick);
+        mBinding.control.action.text.setDownListener(this::onSubtitleClick);
+        mBinding.control.action.home.setOnClickListener(view -> onHome());
+        mBinding.control.action.line.setOnClickListener(view -> onLine());
+        mBinding.control.action.scale.setOnClickListener(view -> onScale());
+        mBinding.control.action.speed.setOnClickListener(view -> onSpeed());
+        mBinding.control.action.config.setOnClickListener(view -> onConfig());
+        mBinding.control.action.action.setOnClickListener(view -> onAction());
+        mBinding.control.action.invert.setOnClickListener(view -> onInvert());
+        mBinding.control.action.across.setOnClickListener(view -> onAcross());
+        mBinding.control.action.change.setOnClickListener(view -> onChange());
+        mBinding.control.action.player.setOnClickListener(view -> onChoose());
+        mBinding.control.action.decode.setOnClickListener(view -> onDecode());
+        mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong());
+        mBinding.video.setOnTouchListener((view, event) -> mKeyDown.onTouchEvent(event));
+        mBinding.group.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() {
+            @Override
+            public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) {
+                if (mGroupAdapter.getItemCount() > 0) onChildSelected(child, mGroup = mGroupAdapter.get(position));
+            }
+        });
+    }
+
+    private void setRecyclerView() {
+        mBinding.group.setItemAnimator(null);
+        mBinding.channel.setItemAnimator(null);
+        mBinding.epgData.setItemAnimator(null);
+        mBinding.group.setAdapter(mGroupAdapter = new GroupAdapter(this));
+        mBinding.channel.setAdapter(mChannelAdapter = new ChannelAdapter(this));
+        mBinding.epgData.setAdapter(mEpgDataAdapter = new EpgDataAdapter(this));
+    }
+
+    private void setVideoView() {
+        setScale(Setting.getLiveScale());
+        findViewById(R.id.timeBar).setNextFocusUpId(R.id.config);
+        mBinding.control.action.invert.setActivated(Setting.isInvert());
+        mBinding.control.action.across.setActivated(Setting.isAcross());
+        mBinding.control.action.change.setActivated(Setting.isChange());
+    }
+
+    private void setDecode() {
+        mBinding.control.action.decode.setText(player().getDecodeText());
+    }
+
+    private void setScale(int scale) {
+        Setting.putLiveScale(scale);
+        mBinding.exo.setResizeMode(scale);
+        mBinding.control.action.scale.setText(ResUtil.getStringArray(R.array.select_scale)[scale]);
+    }
+
+    private void setViewModel() {
+        mViewModel = new ViewModelProvider(this).get(LiveViewModel.class);
+        mViewModel.url().observeForever(mObserveUrl);
+        mViewModel.xml().observe(this, this::setEpg);
+        mViewModel.epg().observeForever(mObserveEpg);
+        mViewModel.live().observe(this, live -> {
+            mViewModel.parseXml(live);
+            setGroup(live);
+            setWidth(live);
+        });
+    }
+
+    private void checkLive() {
+        if (isEmpty()) {
+            LiveConfig.get().init().load(getCallback());
+        } else {
+            getLive();
+        }
+    }
+
+    private Callback getCallback() {
+        return new Callback() {
+            @Override
+            public void success() {
+                getLive();
+            }
+
+            @Override
+            public void error(String msg) {
+                Notify.show(msg);
+            }
+        };
+    }
+
+    private void getLive() {
+        mBinding.control.action.home.setText(LiveConfig.isOnly() ? getString(R.string.live_refresh) : getHome().getName());
+        mViewModel.parse(getHome());
+        showProgress();
+    }
+
+    private void setGroup(Live live) {
+        List<Group> items = new ArrayList<>();
+        for (Group group : live.getGroups()) (group.isHidden() ? mHides : items).add(group);
+        mGroupAdapter.addAll(items);
+        setPosition(LiveConfig.get().findKeepPosition(items));
+    }
+
+    private void setWidth(Live live) {
+        int padding = ResUtil.dp2px(52);
+        if (live.getWidth() == 0) for (Group item : live.getGroups()) live.setWidth(Math.max(live.getWidth(), ResUtil.getTextWidth(item.getName(), 16)));
+        int width = live.getWidth() == 0 ? 0 : Math.min(live.getWidth() + padding, ResUtil.getScreenWidth() / 4);
+        setWidth(mBinding.group, width);
+    }
+
+    private Group setWidth(Group group) {
+        int logo = ResUtil.dp2px(60);
+        int padding = ResUtil.dp2px(64);
+        if (group.isKeep()) group.setWidth(0);
+        if (group.getWidth() == 0) for (Channel item : group.getChannel()) group.setWidth(Math.max(group.getWidth(), (item.getLogo().isEmpty() ? 0 : logo) + ResUtil.getTextWidth(item.getNumber() + item.getName(), 16)));
+        int width = group.getWidth() == 0 ? 0 : Math.min(group.getWidth() + padding, ResUtil.getScreenWidth() / 2);
+        setWidth(mBinding.channel, width);
+        return group;
+    }
+
+    private void setWidth(Epg epg) {
+        int padding = ResUtil.dp2px(52);
+        if (epg.getList().isEmpty()) return;
+        int minWidth = ResUtil.getTextWidth(epg.getList().get(0).getTime(), 14);
+        if (epg.getWidth() == 0) for (EpgData item : epg.getList()) epg.setWidth(Math.max(epg.getWidth(), ResUtil.getTextWidth(item.getTitle(), 16)));
+        int width = epg.getWidth() == 0 ? 0 : Math.min(Math.max(epg.getWidth(), minWidth) + padding, ResUtil.getScreenWidth() / 2);
+        setWidth(mBinding.epgData, width);
+    }
+
+    private void setWidth(View view, int width) {
+        view.post(() -> {
+            ViewGroup.LayoutParams params = view.getLayoutParams();
+            if (params.width == width) return;
+            params.width = width;
+            view.setLayoutParams(params);
+        });
+    }
+
+    private void setPosition(int[] position) {
+        if (position[0] == -1) return;
+        int size = mGroupAdapter.getItemCount();
+        if (size == 1 || position[0] >= size) return;
+        mGroup = mGroupAdapter.get(position[0]);
+        mBinding.group.setSelectedPosition(position[0]);
+        mGroup.setPosition(position[1]);
+        onItemClick(mGroup);
+        onItemClick(mGroup.current());
+    }
+
+    private void setPosition() {
+        if (mChannel == null) return;
+        mGroup = mChannel.getGroup();
+        int position = mGroupAdapter.indexOf(mGroup);
+        boolean change = mBinding.group.getSelectedPosition() != position;
+        if (change) mBinding.group.setSelectedPosition(position);
+        if (change) mChannelAdapter.addAll(mGroup.getChannel());
+        mBinding.channel.setSelectedPosition(mGroup.getPosition());
+    }
+
+    private void onChildSelected(@Nullable RecyclerView.ViewHolder child, Group group) {
+        if (mOldView != null) mOldView.setSelected(false);
+        if ((mOldView = child != null ? child.itemView : null) == null) return;
+        mOldView.setSelected(true);
+        onItemClick(group);
+        resetPass();
+    }
+
+    private void setActivated() {
+        mChannelAdapter.setSelected(mChannel);
+        notifyItemChanged(mBinding.channel, mChannelAdapter);
+        fetch();
+    }
+
+    private void setActivated(EpgData item) {
+        mEpgDataAdapter.setSelected(item);
+        notifyItemChanged(mBinding.epgData, mEpgDataAdapter);
+    }
+
+    private void checkPlay() {
+        if (player().isPlaying()) onPaused();
+        else onPlay();
+    }
+
+    private void onTrack(View view) {
+        TrackDialog.create().type(Integer.parseInt(view.getTag().toString())).player(player()).show(this);
+        hideControl();
+    }
+
+    private void onHome() {
+        if (LiveConfig.isOnly()) setLive(getHome());
+        else LiveDialog.create(this).show();
+        hideControl();
+    }
+
+    private void onLine() {
+        nextLine(false);
+    }
+
+    private void onScale() {
+        int index = Setting.getLiveScale();
+        String[] array = ResUtil.getStringArray(R.array.select_scale);
+        setScale(index == array.length - 1 ? 0 : ++index);
+    }
+
+    private void onSpeed() {
+        mBinding.control.action.speed.setText(player().addSpeed());
+    }
+
+    private void onSpeedAdd() {
+        mBinding.control.action.speed.setText(player().addSpeed(0.25f));
+    }
+
+    private void onSpeedSub() {
+        mBinding.control.action.speed.setText(player().subSpeed(0.25f));
+    }
+
+    private boolean onSpeedLong() {
+        mBinding.control.action.speed.setText(player().toggleSpeed());
+        return true;
+    }
+
+    private void onConfig() {
+        HistoryDialog.create(this).readOnly().type(1).show();
+        hideControl();
+    }
+
+    private void onAction() {
+        checkPlay();
+    }
+
+    private void onInvert() {
+        Setting.putInvert(!Setting.isInvert());
+        mBinding.control.action.invert.setActivated(Setting.isInvert());
+    }
+
+    private void onAcross() {
+        Setting.putAcross(!Setting.isAcross());
+        mBinding.control.action.across.setActivated(Setting.isAcross());
+    }
+
+    private void onChange() {
+        Setting.putChange(!Setting.isChange());
+        mBinding.control.action.change.setActivated(Setting.isChange());
+    }
+
+    private void onChoose() {
+        PlayerHelper.choose(this, player().getUrl(), player().getHeaders(), player().isVod(), player().getPosition(), mBinding.widget.title.getText());
+        setRedirect(true);
+    }
+
+    private void onDecode() {
+        player().toggleDecode();
+        setDecode();
+    }
+
+    private void hideUI() {
+        App.removeCallbacks(mR4);
+        if (isGone(mBinding.recycler)) return;
+        mBinding.recycler.setVisibility(View.GONE);
+        setPosition();
+    }
+
+    private void showUI() {
+        if (isVisible(mBinding.recycler) || mGroupAdapter.getItemCount() == 0) return;
+        mBinding.recycler.setVisibility(View.VISIBLE);
+        setPosition();
+        setUITimer();
+        hideEpg();
+    }
+
+    private final PlaybackService.NavigationCallback mNavigationCallback = new PlaybackService.NavigationCallback() {
+        @Override
+        public void onNext() {
+            nextChannel();
+        }
+
+        @Override
+        public void onPrev() {
+            prevChannel();
+        }
+
+        @Override
+        public void onStop() {
+            finish();
+        }
+    };
+
+    @Override
+    protected void onPrepare() {
+        setDecode();
+    }
+
+    @Override
+    protected void onTracksChanged() {
+        setTrackVisible();
+    }
+
+    @Override
+    protected void onError(String msg) {
+        Track.delete(player().getKey());
+        player().resetTrack();
+        player().reset();
+        player().stop();
+        showError(msg);
+        startFlow();
+    }
+
+    @Override
+    protected void onReclaim() {
+        Result result = mViewModel.url().getValue();
+        if (result != null) start(result);
+    }
+
+    @Override
+    protected void onStateChanged(int state) {
+        switch (state) {
+            case Player.STATE_BUFFERING:
+                showProgress();
+                break;
+            case Player.STATE_READY:
+                hideProgress();
+                player().reset();
+                break;
+            case Player.STATE_ENDED:
+                checkEnded();
+                break;
+        }
+    }
+
+    @Override
+    protected void onPlayingChanged(boolean isPlaying) {
+        if (isPlaying) {
+            mBinding.control.action.action.setText(R.string.pause);
+        } else if (isPaused()) {
+            mBinding.control.action.action.setText(R.string.play);
+        }
+    }
+
+    @Override
+    protected void onSizeChanged(VideoSize size) {
+        mBinding.widget.size.setText(player().getSizeText());
+    }
+
+    @Override
+    public void showEpg(Channel item) {
+        if (mChannel == null || mChannel.getData(mViewModel.getZoneId()).getList().isEmpty() || mEpgDataAdapter.getItemCount() == 0 || !mChannel.equals(item) || !mChannel.getGroup().equals(mGroup)) return;
+        mBinding.epgData.setSelectedPosition(mChannel.getData(mViewModel.getZoneId()).getSelected());
+        mBinding.epgData.setVisibility(View.VISIBLE);
+        mBinding.channel.setVisibility(View.GONE);
+        mBinding.group.setVisibility(View.GONE);
+        mBinding.epgData.requestFocus();
+    }
+
+    @Override
+    public void hideEpg() {
+        mBinding.channel.setVisibility(View.VISIBLE);
+        mBinding.group.setVisibility(View.VISIBLE);
+        mBinding.epgData.setVisibility(View.GONE);
+        mBinding.channel.requestFocus();
+    }
+
+    private void showProgress() {
+        mBinding.progress.getRoot().setVisibility(View.VISIBLE);
+        App.post(mR2, 0);
+        hideCenter();
+        hideError();
+    }
+
+    private void hideProgress() {
+        mBinding.progress.getRoot().setVisibility(View.GONE);
+        App.removeCallbacks(mR2);
+        Traffic.reset();
+    }
+
+    private void showError(String text) {
+        mBinding.widget.error.setVisibility(View.VISIBLE);
+        mBinding.widget.text.setText(text);
+        hideProgress();
+    }
+
+    private void hideError() {
+        mBinding.widget.error.setVisibility(View.GONE);
+        mBinding.widget.text.setText("");
+    }
+
+    private void showControl(View view) {
+        mBinding.control.getRoot().setVisibility(View.VISIBLE);
+        mBinding.widget.top.setVisibility(View.VISIBLE);
+        App.post(view::requestFocus, 25);
+        setR1Callback();
+        hideInfo();
+    }
+
+    private void hideControl() {
+        mBinding.control.getRoot().setVisibility(View.GONE);
+        mBinding.widget.top.setVisibility(View.GONE);
+        App.removeCallbacks(mR1);
+    }
+
+    private void hideCenter() {
+        mBinding.widget.action.setImageResource(R.drawable.ic_widget_play);
+        mBinding.widget.center.setVisibility(View.GONE);
+    }
+
+    private void showInfo() {
+        mBinding.widget.bottom.setVisibility(View.VISIBLE);
+        setR3Callback();
+        setInfo();
+    }
+
+    private void hideInfo() {
+        mBinding.widget.bottom.setVisibility(View.GONE);
+        App.removeCallbacks(mR3);
+    }
+
+    private void setTraffic() {
+        Traffic.setSpeed(mBinding.progress.traffic);
+        App.post(mR2, 1000);
+    }
+
+    private void setR1Callback() {
+        App.post(mR1, Constant.INTERVAL_HIDE);
+    }
+
+    private void setR3Callback() {
+        App.post(mR3, Constant.INTERVAL_HIDE);
+    }
+
+    private void onToggle() {
+        if (isVisible(mBinding.control.getRoot())) hideControl();
+        else if (isVisible(mBinding.recycler)) hideUI();
+        else showUI();
+        hideInfo();
+    }
+
+    private void resetPass() {
+        this.count = 0;
+    }
+
+    private void setArtwork() {
+        ImgUtil.load(this, mChannel.getLogo(), new CustomTarget<>() {
+            @Override
+            public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
+                mBinding.exo.setDefaultArtwork(resource);
+            }
+
+            @Override
+            public void onLoadFailed(@Nullable Drawable errorDrawable) {
+                mBinding.exo.setDefaultArtwork(errorDrawable);
+            }
+        });
+    }
+
+    @Override
+    public void onItemClick(Group item) {
+        mChannelAdapter.addAll(setWidth(item).getChannel());
+        mBinding.channel.setSelectedPosition(Math.max(item.getPosition(), 0));
+        if (!item.isKeep() || ++count < 5 || mHides.isEmpty()) return;
+        PassDialog.create().show(this);
+        App.removeCallbacks(mR4);
+        resetPass();
+    }
+
+    @Override
+    public void onItemClick(Channel item) {
+        if (!item.getData(mViewModel.getZoneId()).getList().isEmpty() && item.isSelected() && mChannel != null && mChannel.equals(item) && mChannel.getGroup().equals(mGroup)) {
+            showEpg(item);
+        } else if (mGroup != null) {
+            mGroup.setPosition(mBinding.channel.getSelectedPosition());
+            setChannel(item.group(mGroup));
+            hideUI();
+        }
+    }
+
+    @Override
+    public boolean onLongClick(Channel item) {
+        if (mGroup.isHidden()) return false;
+        boolean exist = Keep.exist(item.getName());
+        Notify.show(exist ? R.string.keep_del : R.string.keep_add);
+        if (exist) delKeep(item);
+        else addKeep(item);
+        return true;
+    }
+
+    @Override
+    public void onItemClick(EpgData item) {
+        if (item.isSelected()) {
+            fetch(item);
+        } else if (mChannel.hasCatchup() || mChannel.isRtsp()) {
+            mBinding.widget.title.setText(getString(R.string.detail_title, mChannel.getShow(), item.getTitle()));
+            Notify.show(getString(R.string.play_ready, item.getTitle()));
+            setActivated(item);
+            fetch(item);
+        }
+    }
+
+    private void addKeep(Channel item) {
+        getKeep().add(item);
+        Keep keep = new Keep();
+        keep.setKey(item.getName());
+        keep.setType(1);
+        keep.save();
+    }
+
+    private void delKeep(Channel item) {
+        if (mGroup.isKeep()) mChannelAdapter.remove(item);
+        if (mChannelAdapter.getItemCount() == 0) mBinding.group.requestFocus();
+        getKeep().getChannel().remove(item);
+        Keep.delete(item.getName());
+    }
+
+    private void setChannel(Channel item) {
+        App.post(mR0, 100);
+        mChannel = item;
+        setArtwork();
+        showInfo();
+    }
+
+    private void setInfo() {
+        mViewModel.getEpg(mChannel);
+        mBinding.widget.play.setText("");
+        mBinding.widget.name.setMaxEms(48);
+        mChannel.loadLogo(mBinding.widget.logo);
+        mBinding.widget.title.setSelected(true);
+        mBinding.widget.line.setText(mChannel.getLine());
+        mBinding.widget.name.setText(mChannel.getShow());
+        mBinding.widget.title.setText(mChannel.getShow());
+        mBinding.control.action.line.setText(mChannel.getLine());
+        mBinding.widget.number.setText(mChannel.getNumber());
+        mBinding.widget.line.setVisibility(mChannel.getLineVisible());
+        mBinding.control.action.line.setVisibility(mChannel.getLineVisible());
+    }
+
+    private void setEpg(Epg epg) {
+        if (mChannel == null || !mChannel.getTvgId().equals(epg.getKey())) return;
+        EpgData data = epg.getEpgData();
+        boolean hasTitle = !data.getTitle().isEmpty();
+        mEpgDataAdapter.addAll(epg.getList());
+        if (hasTitle) mBinding.widget.title.setText(getString(R.string.detail_title, mChannel.getShow(), data.getTitle()));
+        mBinding.widget.name.setMaxEms(hasTitle ? 12 : 48);
+        mBinding.widget.play.setText(data.format());
+        setWidth(epg);
+        setMetadata();
+    }
+
+    private void setEpg(boolean success) {
+        if (mChannel != null && success) mViewModel.getEpg(mChannel);
+    }
+
+    private void fetch(EpgData item) {
+        if (mChannel == null) return;
+        mViewModel.getUrl(mChannel, item);
+        player().clear();
+        player().stop();
+        hideUI();
+    }
+
+    private void fetch() {
+        if (mChannel == null) return;
+        LiveConfig.get().setKeep(mChannel);
+        mViewModel.getUrl(mChannel);
+        player().clear();
+        player().stop();
+        showProgress();
+    }
+
+    private void start(Result result) {
+        mPlaybackKey = result.getRealUrl();
+        startPlayer(mPlaybackKey, result, false, getHome().getTimeout(), buildMetadata());
+    }
+
+    private void resetAdapter() {
+        mBinding.control.action.line.setVisibility(View.GONE);
+        mBinding.widget.title.setText("");
+        mEpgDataAdapter.clear();
+        mChannelAdapter.clear();
+        mGroupAdapter.clear();
+        mHides.clear();
+        mChannel = null;
+        mGroup = null;
+    }
+
+    @Override
+    public void onSubtitleClick() {
+        SubtitleDialog.create().view(mBinding.exo.getSubtitleView()).full(true).show(this);
+        App.post(this::hideControl, 100);
+    }
+
+    @Override
+    public void setConfig(Config config) {
+        Config current = LiveConfig.get().getConfig();
+        LiveConfig.load(config, getCallback(current));
+    }
+
+    private Callback getCallback(Config config) {
+        return new Callback() {
+            @Override
+            public void start() {
+                showProgress();
+            }
+
+            @Override
+            public void success() {
+                setLive(getHome());
+            }
+
+            @Override
+            public void error(String msg) {
+                LiveConfig.load(config, new Callback());
+                Notify.show(msg);
+                hideProgress();
+            }
+        };
+    }
+
+    @Override
+    public void setLive(Live item) {
+        if (item.isActivated()) item.getGroups().clear();
+        LiveConfig.get().setHome(item);
+        player().reset();
+        player().clear();
+        player().stop();
+        resetAdapter();
+        hideControl();
+        getLive();
+    }
+
+    @Override
+    public void setPass(String pass) {
+        unlock(pass);
+    }
+
+    private void unlock(String pass) {
+        boolean first = true;
+        int position = mGroupAdapter.getItemCount();
+        Iterator<Group> iterator = mHides.iterator();
+        while (iterator.hasNext()) {
+            Group item = iterator.next();
+            if (pass != null && !pass.equals(item.getPass())) continue;
+            mGroupAdapter.add(mGroupAdapter.getItemCount(), item);
+            if (first) mBinding.group.setSelectedPosition(position);
+            if (first) onItemClick(mGroup = item);
+            iterator.remove();
+            first = false;
+        }
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onRefreshEvent(RefreshEvent event) {
+        switch (event.getType()) {
+            case LIVE:
+                setLive(getHome());
+                break;
+            case PLAYER:
+                fetch();
+                break;
+        }
+    }
+
+    private void checkEnded() {
+        if (player().isLive()) {
+            checkNext();
+        } else {
+            nextChannel();
+        }
+    }
+
+    private void setTrackVisible() {
+        mBinding.control.action.text.setVisibility(player().haveTrack(C.TRACK_TYPE_TEXT) || player().isVod() ? View.VISIBLE : View.GONE);
+        mBinding.control.action.audio.setVisibility(player().haveTrack(C.TRACK_TYPE_AUDIO) ? View.VISIBLE : View.GONE);
+        mBinding.control.action.video.setVisibility(player().haveTrack(C.TRACK_TYPE_VIDEO) ? View.VISIBLE : View.GONE);
+        mBinding.control.action.speed.setVisibility(player().isVod() ? View.VISIBLE : View.GONE);
+    }
+
+    private MediaMetadata buildMetadata() {
+        String artist = mBinding.widget.play.getText().toString();
+        return PlayerManager.buildMetadata(mChannel.getShow(), artist, mChannel.getLogo());
+    }
+
+    private void setMetadata() {
+        player().setMetadata(buildMetadata());
+    }
+
+    private void startFlow() {
+        if (!Setting.isChange()) return;
+        if (!mChannel.isLast()) nextLine(true);
+    }
+
+    private void prevChannel() {
+        if (mGroup == null) return;
+        int position = mGroup.getPosition() - 1;
+        boolean limit = position < 0;
+        if (Setting.isAcross() & limit) prevGroup();
+        else mGroup.setPosition(limit ? mChannelAdapter.getItemCount() - 1 : position);
+        if (!mGroup.isEmpty()) setChannel(mGroup.current());
+    }
+
+    private void nextChannel() {
+        if (mGroup == null) return;
+        int position = mGroup.getPosition() + 1;
+        boolean limit = position > mChannelAdapter.getItemCount() - 1;
+        if (Setting.isAcross() && limit) nextGroup();
+        else mGroup.setPosition(limit ? 0 : position);
+        if (!mGroup.isEmpty()) setChannel(mGroup.current());
+    }
+
+    private boolean nextGroup() {
+        int position = mBinding.group.getSelectedPosition() + 1;
+        if (position > mGroupAdapter.getItemCount() - 1) position = 0;
+        if (mGroup.equals(mGroupAdapter.get(position))) return false;
+        mGroup = mGroupAdapter.get(position);
+        mBinding.group.setSelectedPosition(position);
+        if (mGroup.skip()) return nextGroup();
+        mChannelAdapter.addAll(mGroup.getChannel());
+        mGroup.setPosition(0);
+        return true;
+    }
+
+    private boolean prevGroup() {
+        int position = mBinding.group.getSelectedPosition() - 1;
+        if (position < 0) position = mGroupAdapter.getItemCount() - 1;
+        if (mGroup.equals(mGroupAdapter.get(position))) return false;
+        mGroup = mGroupAdapter.get(position);
+        mBinding.group.setSelectedPosition(position);
+        if (mGroup.skip()) return prevGroup();
+        mChannelAdapter.addAll(mGroup.getChannel());
+        mGroup.setPosition(mGroup.getChannel().size() - 1);
+        return true;
+    }
+
+    private void checkNext() {
+        int current = mChannel.getData(mViewModel.getZoneId()).getInRange();
+        int position = mChannel.getData(mViewModel.getZoneId()).getSelected() + 1;
+        boolean hasNext = position <= current && position > 0;
+        if (hasNext) onItemClick(mChannel.getData(mViewModel.getZoneId()).getList().get(position));
+        else fetch();
+    }
+
+    private void prevLine() {
+        if (mChannel == null || mChannel.isOnly()) return;
+        mChannel.switchLine(false);
+        showInfo();
+        fetch();
+    }
+
+    private void nextLine(boolean show) {
+        if (mChannel == null || mChannel.isOnly()) return;
+        mChannel.switchLine(true);
+        if (show) showInfo();
+        else setInfo();
+        fetch();
+    }
+
+    private void seek(long time) {
+        mKeyDown.reset();
+        seekTo(time);
+    }
+
+    private void onPaused() {
+        controller().pause();
+    }
+
+    private void onPlay() {
+        controller().play();
+    }
+
+    private View getFocus2() {
+        return mFocus2 == null || mFocus2.getVisibility() != View.VISIBLE ? mBinding.control.action.config : mFocus2;
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (isVisible(mBinding.control.getRoot())) setR1Callback();
+        if (isVisible(mBinding.control.getRoot())) mFocus2 = getCurrentFocus();
+        if (mKeyDown.hasEvent(event) && service() != null) mKeyDown.onKeyDown(event);
+        return super.dispatchKeyEvent(event);
+    }
+
+    @Override
+    public void setUITimer() {
+        App.post(mR4, Constant.INTERVAL_HIDE);
+    }
+
+    @Override
+    public boolean dispatch(boolean check) {
+        return !check || isGone(mBinding.recycler) && isGone(mBinding.control.getRoot());
+    }
+
+    @Override
+    public void onShow(String number) {
+        mBinding.widget.digital.setText(number);
+        mBinding.widget.digital.setVisibility(View.VISIBLE);
+    }
+
+    @Override
+    public void onFind(String number) {
+        mBinding.widget.digital.setVisibility(View.GONE);
+        setPosition(LiveConfig.get().findByChannelNumber(number, mGroupAdapter.unmodifiableList()));
+    }
+
+    @Override
+    public void onSeeking(long time) {
+        if (player().isLive()) return;
+        mBinding.widget.center.setVisibility(View.VISIBLE);
+        mBinding.widget.duration.setText(player().getDurationTime());
+        mBinding.widget.position.setText(player().getPositionTime(time));
+        mBinding.widget.action.setImageResource(time > 0 ? R.drawable.ic_widget_forward : R.drawable.ic_widget_rewind);
+        hideProgress();
+    }
+
+    @Override
+    public void onKeyUp() {
+        if (Setting.isInvert()) nextChannel();
+        else prevChannel();
+    }
+
+    @Override
+    public void onKeyDown() {
+        if (Setting.isInvert()) prevChannel();
+        else nextChannel();
+    }
+
+    @Override
+    public void onKeyLeft(long time) {
+        if (player().isLive()) prevLine();
+        else App.post(() -> seek(time), 250);
+    }
+
+    @Override
+    public void onKeyRight(long time) {
+        if (player().isLive()) nextLine(true);
+        else App.post(() -> seek(time), 250);
+    }
+
+    @Override
+    public void onKeyCenter() {
+        hideInfo();
+        showUI();
+    }
+
+    @Override
+    public void onMenu() {
+        showControl(getFocus2());
+    }
+
+    @Override
+    public void onSingleTap() {
+        onToggle();
+    }
+
+    @Override
+    public void onDoubleTap() {
+        if (isVisible(mBinding.recycler)) hideUI();
+        else if (isVisible(mBinding.control.getRoot())) hideControl();
+        else onMenu();
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        mClock.stop().start();
+    }
+
+    @Override
+    protected void onStop() {
+        super.onStop();
+        if (Setting.isBackgroundOff()) mClock.stop();
+    }
+
+    @Override
+    protected void onBackInvoked() {
+        if (isVisible(mBinding.control.getRoot())) {
+            hideControl();
+        } else if (isVisible(mBinding.widget.bottom)) {
+            hideInfo();
+        } else if (isVisible(mBinding.recycler)) {
+            hideUI();
+        } else {
+            if (isTaskRoot()) startActivity(new Intent(this, HomeActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP));
+            super.onBackInvoked();
+        }
+    }
+
+    @Override
+    protected void onDestroy() {
+        mClock.release();
+        Source.get().exit();
+        App.removeCallbacks(mR0, mR1, mR2, mR3, mR4);
+        mViewModel.url().removeObserver(mObserveUrl);
+        mViewModel.epg().removeObserver(mObserveEpg);
+        super.onDestroy();
+    }
+}

+ 66 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/PushActivity.java

@@ -0,0 +1,66 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.View;
+
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.databinding.ActivityPushBinding;
+import com.fongmi.android.tv.server.Server;
+import com.fongmi.android.tv.ui.base.BaseActivity;
+import com.fongmi.android.tv.utils.QRCode;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.fongmi.android.tv.utils.Sniffer;
+import com.fongmi.android.tv.utils.Util;
+
+public class PushActivity extends BaseActivity {
+
+    private ActivityPushBinding mBinding;
+
+    public static void start(Activity activity) {
+        start(activity, 2);
+    }
+
+    public static void start(Activity activity, int tab) {
+        Intent intent = new Intent(activity, PushActivity.class);
+        intent.putExtra("tab", tab);
+        activity.startActivity(intent);
+    }
+
+    private int getTab() {
+        return getIntent().getIntExtra("tab", 2);
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivityPushBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        mBinding.code.setImageBitmap(QRCode.getBitmap(Server.get().getAddress(getTab()), 250, 1));
+        mBinding.info.setText(ResUtil.getString(R.string.push_info, Server.get().getAddress()));
+    }
+
+    @Override
+    protected void initEvent() {
+        mBinding.code.setOnClickListener(this::onCode);
+        mBinding.clip.setOnClickListener(this::onClip);
+    }
+
+    private void onClip(View view) {
+        CharSequence text = Util.getClipText();
+        if (!TextUtils.isEmpty(text)) VideoActivity.start(this, Sniffer.getUrl(text.toString()));
+    }
+
+    private void onCode(View view) {
+        Intent intent = new Intent(Intent.ACTION_VIEW);
+        intent.setData(Uri.parse(Server.get().getAddress(getTab())));
+        startActivity(intent);
+    }
+}

+ 320 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/SearchActivity.java

@@ -0,0 +1,320 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.bean.Word;
+import com.fongmi.android.tv.databinding.ActivitySearchBinding;
+import com.fongmi.android.tv.impl.Callback;
+import com.fongmi.android.tv.ui.adapter.RecordAdapter;
+import com.fongmi.android.tv.ui.adapter.WordAdapter;
+import com.fongmi.android.tv.ui.base.BaseActivity;
+import com.fongmi.android.tv.ui.custom.CustomKeyboard;
+import com.fongmi.android.tv.ui.custom.CustomTextListener;
+import com.fongmi.android.tv.ui.dialog.SiteDialog;
+import com.fongmi.android.tv.utils.KeyUtil;
+import com.fongmi.android.tv.utils.Util;
+import com.fongmi.android.tv.utils.ZhuToPin;
+import com.github.catvod.net.OkHttp;
+import com.google.android.flexbox.FlexDirection;
+import com.google.android.flexbox.FlexboxLayoutManager;
+import com.google.common.net.HttpHeaders;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.util.Map;
+
+import okhttp3.Call;
+import okhttp3.Response;
+
+public class SearchActivity extends BaseActivity implements WordAdapter.OnClickListener, RecordAdapter.OnClickListener, CustomKeyboard.Callback {
+
+    private ActivitySearchBinding mBinding;
+    private RecordAdapter mRecordAdapter;
+    private WordAdapter mWordAdapter;
+
+    public static void start(Activity activity) {
+        activity.startActivity(new Intent(activity, SearchActivity.class));
+    }
+
+    public static void start(Activity activity, String keyword) {
+        Intent intent = new Intent(activity, SearchActivity.class);
+        intent.putExtra("keyword", keyword);
+        activity.startActivity(intent);
+    }
+
+    private String getKeyword() {
+        String keyword = getIntent().getStringExtra("keyword");
+        return keyword != null ? keyword : "";
+    }
+
+    private boolean empty() {
+        return mBinding.keyword.getText().toString().trim().isEmpty();
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivitySearchBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        CustomKeyboard.init(this, mBinding);
+        setRecyclerView();
+        checkKeyword();
+        onSearch();
+    }
+
+    @Override
+    protected void initEvent() {
+        mBinding.keyword.setOnEditorActionListener((textView, actionId, event) -> {
+            if (actionId == EditorInfo.IME_ACTION_DONE) onSearch();
+            return true;
+        });
+        mBinding.keyword.addTextChangedListener(new CustomTextListener() {
+            @Override
+            public void afterTextChanged(Editable s) {
+                getWord(s.toString());
+            }
+        });
+        mBinding.mic.setOnClickListener(v -> mBinding.mic.start());
+        mBinding.mic.setListener(this, new CustomTextListener() {
+            @Override
+            public void onResults(String result) {
+                if (!result.isEmpty()) setKeyword(result);
+                mBinding.keyword.requestFocus();
+            }
+        });
+    }
+
+    private void setRecyclerView() {
+        mBinding.wordRecycler.setItemAnimator(null);
+        mBinding.wordRecycler.setHasFixedSize(false);
+        mBinding.wordRecycler.setLayoutManager(new FlexboxLayoutManager(this, FlexDirection.ROW));
+        mBinding.wordRecycler.setAdapter(mWordAdapter = new WordAdapter(this));
+        mBinding.recordRecycler.setHasFixedSize(false);
+        mBinding.recordRecycler.setLayoutManager(new FlexboxLayoutManager(this, FlexDirection.ROW));
+        mBinding.recordRecycler.setAdapter(mRecordAdapter = new RecordAdapter(this));
+    }
+
+    private void checkKeyword() {
+        setKeyword(getKeyword());
+        getWord(getKeyword());
+    }
+
+    private void setKeyword(String text) {
+        mBinding.keyword.setText(text);
+        mBinding.keyword.setSelection(text.length());
+    }
+
+    private void getWord(String text) {
+        if (text.isEmpty()) getHot();
+        else getSuggest(text);
+    }
+
+    private void getHot() {
+        mBinding.word.setText(R.string.search_hot);
+        mWordAdapter.setItems(Word.objectFrom(Setting.getHot()).getData());
+        OkHttp.newCall("https://api.web.360kan.com/v1/rank?cat=1", Map.of(HttpHeaders.REFERER, "https://www.360kan.com/rank/general")).enqueue(getCallback(true));
+    }
+
+    private void getSuggest(String text) {
+        mBinding.word.setText(R.string.search_suggest);
+        OkHttp.newCall("https://suggest.video.iqiyi.com/?if=mobile&key=" + URLEncoder.encode(ZhuToPin.get(text))).enqueue(getCallback(false));
+    }
+
+    private Callback getCallback(boolean hot) {
+        return new Callback() {
+            @Override
+            public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
+                String result = response.body().string();
+                if (TextUtils.isEmpty(result)) return;
+                App.post(() -> setAdapter(result, hot));
+            }
+        };
+    }
+
+    private void setAdapter(String result, boolean save) {
+        if (!save && empty()) return;
+        if (save) Setting.putHot(result);
+        mWordAdapter.setItems(Word.objectFrom(result).getData());
+    }
+
+    @Override
+    public void onItemClick(String text) {
+        setKeyword(text);
+        onSearch();
+    }
+
+    @Override
+    public void onDataChanged(int size) {
+        mBinding.recordLayout.setVisibility(size == 0 ? View.GONE : View.VISIBLE);
+        if (size == 0) focusFirst(mBinding.wordRecycler);
+    }
+
+    @Override
+    public void onSearch() {
+        if (empty()) return;
+        String keyword = mBinding.keyword.getText().toString().trim();
+        App.post(() -> mRecordAdapter.add(keyword), 250);
+        Util.hideKeyboard(mBinding.keyword);
+        CollectActivity.start(this, keyword);
+    }
+
+    @Override
+    public void showDialog() {
+        SiteDialog.create(this).search().show();
+    }
+
+    @Override
+    public void onRemote() {
+        PushActivity.start(this, 1);
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (KeyUtil.isMenuKey(event)) showDialog();
+        if (KeyUtil.isActionDown(event) && findFocus(event)) return true;
+        return super.dispatchKeyEvent(event);
+    }
+
+    private boolean findFocus(KeyEvent event) {
+        int keyCode = event.getKeyCode();
+        View current = getCurrentFocus();
+        if (current == mBinding.keyword) return handleKeywordKey(keyCode);
+        View inKeyboard = mBinding.keyboard.findContainingItemView(current);
+        View inWord = mBinding.wordRecycler.findContainingItemView(current);
+        View inRecord = mBinding.recordRecycler.findContainingItemView(current);
+        if (inKeyboard != null) return handleKeyboardKey(keyCode, inKeyboard);
+        if (inWord != null) return handleWordKey(keyCode, inWord);
+        if (inRecord != null) return handleRecordKey(keyCode, inRecord);
+        return false;
+    }
+
+    private View findNearestInLastRow(RecyclerView rv, int targetLeft) {
+        if (rv.getChildCount() == 0) return null;
+        int lastTop = rv.getChildAt(rv.getChildCount() - 1).getTop();
+        View nearest = null;
+        int minDist = Integer.MAX_VALUE;
+        for (int i = 0; i < rv.getChildCount(); i++) {
+            View child = rv.getChildAt(i);
+            if (child.getTop() == lastTop) {
+                int dist = Math.abs(child.getLeft() - targetLeft);
+                if (dist < minDist) {
+                    minDist = dist;
+                    nearest = child;
+                }
+            }
+        }
+        return nearest;
+    }
+
+    private boolean isFirstRow(RecyclerView rv, View item) {
+        View first = rv.getChildAt(0);
+        return first != null && item.getTop() == first.getTop();
+    }
+
+    private boolean isLastRow(RecyclerView rv, View item) {
+        View last = rv.getChildAt(rv.getChildCount() - 1);
+        return last != null && item.getTop() == last.getTop();
+    }
+
+    private boolean isFirstInRow(RecyclerView rv, View focused) {
+        int top = focused.getTop();
+        int left = focused.getLeft();
+        for (int i = 0; i < rv.getChildCount(); i++) {
+            View child = rv.getChildAt(i);
+            if (child.getTop() == top && child.getLeft() < left) return false;
+        }
+        return true;
+    }
+
+    private boolean isLastInRow(RecyclerView rv, View focused) {
+        int top = focused.getTop();
+        int right = focused.getRight();
+        for (int i = 0; i < rv.getChildCount(); i++) {
+            View child = rv.getChildAt(i);
+            if (child.getTop() == top && child.getRight() > right) return false;
+        }
+        return true;
+    }
+
+    private boolean handleKeywordKey(int keyCode) {
+        if (keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) return false;
+        if (mBinding.keyword.getSelectionEnd() < mBinding.keyword.getText().length()) return false;
+        boolean hasRecord = mBinding.recordLayout.getVisibility() == View.VISIBLE;
+        return focusFirst(hasRecord ? mBinding.recordRecycler : mBinding.wordRecycler);
+    }
+
+    private boolean handleKeyboardKey(int keyCode, View item) {
+        if (keyCode == KeyEvent.KEYCODE_DPAD_UP && isFirstRow(mBinding.keyboard, item)) {
+            mBinding.keyword.requestFocus();
+            return true;
+        }
+        if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && isFirstInRow(mBinding.keyboard, item)) return true;
+        return keyCode == KeyEvent.KEYCODE_DPAD_DOWN && isLastRow(mBinding.keyboard, item);
+    }
+
+    private boolean handleWordKey(int keyCode, View item) {
+        if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) return isLastInRow(mBinding.wordRecycler, item);
+        if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) return isLastRow(mBinding.wordRecycler, item);
+        if (keyCode == KeyEvent.KEYCODE_DPAD_UP && isFirstRow(mBinding.wordRecycler, item)) {
+            if (mBinding.recordLayout.getVisibility() == View.VISIBLE) {
+                View child = findNearestInLastRow(mBinding.recordRecycler, item.getLeft());
+                if (child != null) {
+                    mBinding.scroll.smoothScrollTo(0, 0);
+                    child.requestFocus();
+                    return true;
+                }
+            }
+            return true;
+        }
+        return false;
+    }
+
+    private boolean handleRecordKey(int keyCode, View item) {
+        if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) return isLastInRow(mBinding.recordRecycler, item);
+        if (keyCode == KeyEvent.KEYCODE_DPAD_UP) return isFirstRow(mBinding.recordRecycler, item);
+        if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && isLastRow(mBinding.recordRecycler, item)) return focusFirst(mBinding.wordRecycler);
+        return false;
+    }
+
+    private boolean focusFirst(RecyclerView rv) {
+        View child = rv.getChildAt(0);
+        if (child == null) return false;
+        child.requestFocus();
+        return true;
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+        mBinding.mic.setFocusable(false);
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        mBinding.mic.setFocusable(true);
+        mBinding.keyword.requestFocus();
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        mBinding.mic.destroy();
+    }
+}

+ 346 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java

@@ -0,0 +1,346 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.BuildConfig;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.Updater;
+import com.fongmi.android.tv.api.config.LiveConfig;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.api.config.WallConfig;
+import com.fongmi.android.tv.bean.Config;
+import com.fongmi.android.tv.bean.Live;
+import com.fongmi.android.tv.bean.Site;
+import com.fongmi.android.tv.databinding.ActivitySettingBinding;
+import com.fongmi.android.tv.db.AppDatabase;
+import com.fongmi.android.tv.event.ConfigEvent;
+import com.fongmi.android.tv.event.RefreshEvent;
+import com.fongmi.android.tv.impl.Callback;
+import com.fongmi.android.tv.impl.ConfigCallback;
+import com.fongmi.android.tv.impl.DohCallback;
+import com.fongmi.android.tv.impl.LiveCallback;
+import com.fongmi.android.tv.impl.SiteCallback;
+import com.fongmi.android.tv.ui.base.BaseActivity;
+import com.fongmi.android.tv.ui.dialog.ConfigDialog;
+import com.fongmi.android.tv.ui.dialog.DohDialog;
+import com.fongmi.android.tv.ui.dialog.HistoryDialog;
+import com.fongmi.android.tv.ui.dialog.LiveDialog;
+import com.fongmi.android.tv.ui.dialog.RestoreDialog;
+import com.fongmi.android.tv.ui.dialog.SiteDialog;
+import com.fongmi.android.tv.utils.FileChooser;
+import com.fongmi.android.tv.utils.FileUtil;
+import com.fongmi.android.tv.utils.Notify;
+import com.fongmi.android.tv.utils.PermissionUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.github.catvod.bean.Doh;
+import com.github.catvod.net.OkHttp;
+import com.github.catvod.utils.Path;
+
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SettingActivity extends BaseActivity implements ConfigCallback, SiteCallback, LiveCallback, DohCallback {
+
+    private ActivitySettingBinding mBinding;
+    private String[] size;
+    private int type;
+
+    public static void start(Activity activity) {
+        activity.startActivity(new Intent(activity, SettingActivity.class));
+    }
+
+    private String getSwitch(boolean value) {
+        return getString(value ? R.string.setting_on : R.string.setting_off);
+    }
+
+    private int getDohIndex() {
+        return Math.max(0, VodConfig.get().getDoh().indexOf(Doh.objectFrom(Setting.getDoh())));
+    }
+
+    private String[] getDohList() {
+        List<String> list = new ArrayList<>();
+        for (Doh item : VodConfig.get().getDoh()) list.add(item.getName());
+        return list.toArray(new String[0]);
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivitySettingBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        mBinding.vod.requestFocus();
+        mBinding.vodUrl.setText(VodConfig.getDesc());
+        mBinding.liveUrl.setText(LiveConfig.getDesc());
+        mBinding.wallUrl.setText(WallConfig.getDesc());
+        mBinding.versionText.setText(BuildConfig.VERSION_NAME);
+        setCacheText();
+        setOtherText();
+    }
+
+    private void setOtherText() {
+        mBinding.dohText.setText(getDohList()[getDohIndex()]);
+        setBootLiveText();
+        mBinding.incognitoText.setText(getSwitch(Setting.isIncognito()));
+        mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[Setting.getSize()]);
+    }
+
+    private void setBootLiveText() {
+        mBinding.bootLiveText.setText(getSwitch(Setting.isBootLive()));
+    }
+
+    private void setCacheText() {
+        FileUtil.getCacheSize(new Callback() {
+            @Override
+            public void success(String result) {
+                mBinding.cacheText.setText(result);
+            }
+        });
+    }
+
+    @Override
+    protected void initEvent() {
+        mBinding.vod.setOnClickListener(this::onVod);
+        mBinding.doh.setOnClickListener(this::setDoh);
+        mBinding.live.setOnClickListener(this::onLive);
+        mBinding.wall.setOnClickListener(this::onWall);
+        mBinding.size.setOnClickListener(this::setSize);
+        mBinding.cache.setOnClickListener(this::onCache);
+        mBinding.backup.setOnClickListener(this::onBackup);
+        mBinding.player.setOnClickListener(this::onPlayer);
+        mBinding.restore.setOnClickListener(this::onRestore);
+        mBinding.version.setOnClickListener(this::onVersion);
+        mBinding.vod.setOnLongClickListener(this::onVodEdit);
+        mBinding.vodHome.setOnClickListener(this::onVodHome);
+        mBinding.live.setOnLongClickListener(this::onLiveEdit);
+        mBinding.liveHome.setOnClickListener(this::onLiveHome);
+        mBinding.wall.setOnLongClickListener(this::onWallEdit);
+        mBinding.bootLive.setOnClickListener(this::setBootLive);
+        mBinding.incognito.setOnClickListener(this::setIncognito);
+        mBinding.vodHistory.setOnClickListener(this::onVodHistory);
+        mBinding.liveHistory.setOnClickListener(this::onLiveHistory);
+        mBinding.wallDefault.setOnClickListener(this::setWallDefault);
+        mBinding.wallRefresh.setOnClickListener(this::setWallRefresh);
+        mBinding.wallRefresh.setOnLongClickListener(this::onWallHistory);
+    }
+
+    @Override
+    public void setConfig(Config config) {
+        if (config.getUrl().startsWith("file")) {
+            PermissionUtil.requestFile(this, allGranted -> load(config));
+        } else {
+            load(config);
+        }
+    }
+
+    private void load(Config config) {
+        switch (config.getType()) {
+            case 0:
+                VodConfig.load(config, getCallback());
+                break;
+            case 1:
+                LiveConfig.load(config, getCallback());
+                break;
+            case 2:
+                Setting.putWall(0);
+                WallConfig.load(config, getCallback());
+                break;
+        }
+    }
+
+    private Callback getCallback() {
+        return new Callback() {
+            @Override
+            public void start() {
+                Notify.progress(getActivity());
+            }
+
+            @Override
+            public void success() {
+                Notify.dismiss();
+                setCacheText();
+            }
+
+            @Override
+            public void error(String msg) {
+                Notify.dismiss();
+                Notify.show(msg);
+            }
+        };
+    }
+
+    @Override
+    public void setSite(Site item) {
+        VodConfig.get().setHome(item);
+    }
+
+    @Override
+    public void setLive(Live item) {
+        LiveConfig.get().setHome(item);
+    }
+
+    private void onVod(View view) {
+        ConfigDialog.create(this).launcher(launcher).type(type = 0).show();
+    }
+
+    private void onLive(View view) {
+        ConfigDialog.create(this).launcher(launcher).type(type = 1).show();
+    }
+
+    private void onWall(View view) {
+        ConfigDialog.create(this).launcher(launcher).type(type = 2).show();
+    }
+
+    private boolean onVodEdit(View view) {
+        ConfigDialog.create(this).launcher(launcher).type(type = 0).edit().show();
+        return true;
+    }
+
+    private boolean onLiveEdit(View view) {
+        ConfigDialog.create(this).launcher(launcher).type(type = 1).edit().show();
+        return true;
+    }
+
+    private boolean onWallEdit(View view) {
+        ConfigDialog.create(this).launcher(launcher).type(type = 2).edit().show();
+        return true;
+    }
+
+    private void onVodHome(View view) {
+        SiteDialog.create(this).action().show();
+    }
+
+    private void onLiveHome(View view) {
+        LiveDialog.create(this).action().show();
+    }
+
+    private void onVodHistory(View view) {
+        HistoryDialog.create(this).type(type = 0).show();
+    }
+
+    private void onLiveHistory(View view) {
+        HistoryDialog.create(this).type(type = 1).show();
+    }
+
+    private void onPlayer(View view) {
+        SettingPlayerActivity.start(this);
+    }
+
+    private void onVersion(View view) {
+        Updater.create().force().start(this);
+    }
+
+    private void setWallDefault(View view) {
+        Setting.putWall(Setting.getWall() == 4 ? 1 : Setting.getWall() + 1);
+        Setting.putWallType(0);
+        ConfigEvent.wall();
+    }
+
+    private void setWallRefresh(View view) {
+        Setting.putWall(0);
+        WallConfig.get().load(getCallback());
+    }
+
+    private boolean onWallHistory(View view) {
+        HistoryDialog.create(this).type(type = 2).show();
+        return true;
+    }
+
+    private void setIncognito(View view) {
+        Setting.putIncognito(!Setting.isIncognito());
+        mBinding.incognitoText.setText(getSwitch(Setting.isIncognito()));
+    }
+
+    private void setBootLive(View view) {
+        Setting.putBootLive(!Setting.isBootLive());
+        setBootLiveText();
+    }
+
+    private void setSize(View view) {
+        int index = (Setting.getSize() + 1) % size.length;
+        mBinding.sizeText.setText(size[index]);
+        Setting.putSize(index);
+        RefreshEvent.size();
+    }
+
+    private void setDoh(View view) {
+        DohDialog.create(this).index(getDohIndex()).show();
+    }
+
+    @Override
+    public void setDoh(Doh doh) {
+        OkHttp.dns().setDoh(doh);
+        Setting.putDoh(doh.toString());
+        mBinding.dohText.setText(doh.getName());
+    }
+
+    private void onCache(View view) {
+        FileUtil.clearCache(new Callback() {
+            @Override
+            public void success() {
+                setCacheText();
+            }
+        });
+    }
+
+    private void onBackup(View view) {
+        PermissionUtil.requestFile(this, allGranted -> AppDatabase.backup(new Callback() {
+            @Override
+            public void success() {
+                Notify.show(R.string.backup_success);
+            }
+
+            @Override
+            public void error() {
+                Notify.show(R.string.backup_fail);
+            }
+        }));
+    }
+
+    private void onRestore(View view) {
+        PermissionUtil.requestFile(this, allGranted -> RestoreDialog.create(getActivity()).show(new Callback() {
+            @Override
+            public void success() {
+                Notify.show(R.string.restore_success);
+                setOtherText();
+                initConfig();
+            }
+
+            @Override
+            public void error() {
+                Notify.show(R.string.restore_fail);
+            }
+        }));
+    }
+
+    private void initConfig() {
+        VodConfig.get().init().load(getCallback());
+        LiveConfig.get().init().load();
+        WallConfig.get().init().load();
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onConfigEvent(ConfigEvent event) {
+        if (event.type() != ConfigEvent.Type.COMMON) return;
+        mBinding.vodUrl.setText(VodConfig.getDesc());
+        mBinding.liveUrl.setText(LiveConfig.getDesc());
+        mBinding.wallUrl.setText(WallConfig.getDesc());
+    }
+
+    private final ActivityResultLauncher<Intent> launcher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+        if (result.getResultCode() != RESULT_OK || result.getData() == null || result.getData().getData() == null) return;
+        setConfig(Config.find("file:/" + FileChooser.getPathFromUri(result.getData().getData()).replace(Path.rootPath(), ""), type));
+    });
+}

+ 177 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java

@@ -0,0 +1,177 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.view.View;
+
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.databinding.ActivitySettingPlayerBinding;
+import com.fongmi.android.tv.impl.BufferCallback;
+import com.fongmi.android.tv.impl.SpeedCallback;
+import com.fongmi.android.tv.impl.UaCallback;
+import com.fongmi.android.tv.ui.base.BaseActivity;
+import com.fongmi.android.tv.ui.dialog.BufferDialog;
+import com.fongmi.android.tv.ui.dialog.SpeedDialog;
+import com.fongmi.android.tv.ui.dialog.UaDialog;
+import com.fongmi.android.tv.utils.ResUtil;
+
+import java.text.DecimalFormat;
+
+public class SettingPlayerActivity extends BaseActivity implements UaCallback, BufferCallback, SpeedCallback {
+
+    private ActivitySettingPlayerBinding mBinding;
+    private DecimalFormat format;
+    private String[] caption;
+    private String[] render;
+    private String[] scale;
+
+    public static void start(Activity activity) {
+        activity.startActivity(new Intent(activity, SettingPlayerActivity.class));
+    }
+
+    private String getSwitch(boolean value) {
+        return getString(value ? R.string.setting_on : R.string.setting_off);
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivitySettingPlayerBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        setVisible();
+        format = new DecimalFormat("0.#");
+        mBinding.render.requestFocus();
+        mBinding.uaText.setText(Setting.getUa());
+        mBinding.aacText.setText(getSwitch(Setting.isPreferAAC()));
+        mBinding.tunnelText.setText(getSwitch(Setting.isTunnel()));
+        mBinding.adblockText.setText(getSwitch(Setting.isAdblock()));
+        mBinding.speedText.setText(format.format(Setting.getSpeed()));
+        mBinding.bufferText.setText(String.valueOf(Setting.getBuffer()));
+        mBinding.backgroundText.setText(getSwitch(Setting.isBackgroundOn()));
+        mBinding.audioDecodeText.setText(getSwitch(Setting.isAudioPrefer()));
+        mBinding.videoDecodeText.setText(getSwitch(Setting.isVideoPrefer()));
+        mBinding.danmakuLoadText.setText(getSwitch(Setting.isDanmakuLoad()));
+        mBinding.scaleText.setText((scale = ResUtil.getStringArray(R.array.select_scale))[Setting.getScale()]);
+        mBinding.renderText.setText((render = ResUtil.getStringArray(R.array.select_render))[Setting.getRender()]);
+        mBinding.captionText.setText((caption = ResUtil.getStringArray(R.array.select_caption))[Setting.isCaption() ? 1 : 0]);
+    }
+
+    @Override
+    protected void initEvent() {
+        mBinding.ua.setOnClickListener(this::onUa);
+        mBinding.aac.setOnClickListener(this::setAAC);
+        mBinding.scale.setOnClickListener(this::setScale);
+        mBinding.speed.setOnClickListener(this::onSpeed);
+        mBinding.buffer.setOnClickListener(this::onBuffer);
+        mBinding.render.setOnClickListener(this::setRender);
+        mBinding.tunnel.setOnClickListener(this::setTunnel);
+        mBinding.caption.setOnClickListener(this::setCaption);
+        mBinding.adblock.setOnClickListener(this::setAdblock);
+        mBinding.caption.setOnLongClickListener(this::onCaption);
+        mBinding.background.setOnClickListener(this::onBackground);
+        mBinding.audioDecode.setOnClickListener(this::setAudioDecode);
+        mBinding.videoDecode.setOnClickListener(this::setVideoDecode);
+        mBinding.danmakuLoad.setOnClickListener(this::setDanmakuLoad);
+    }
+
+    private void setVisible() {
+        if (Setting.getBackground() == 2) Setting.putBackground(1);
+        mBinding.caption.setVisibility(Setting.hasCaption() ? View.VISIBLE : View.GONE);
+    }
+
+    private void onUa(View view) {
+        UaDialog.create(this).show();
+    }
+
+    @Override
+    public void setUa(String ua) {
+        mBinding.uaText.setText(ua);
+        Setting.putUa(ua);
+    }
+
+    private void setAAC(View view) {
+        Setting.putPreferAAC(!Setting.isPreferAAC());
+        mBinding.aacText.setText(getSwitch(Setting.isPreferAAC()));
+    }
+
+    private void setScale(View view) {
+        int index = (Setting.getScale() + 1) % scale.length;
+        mBinding.scaleText.setText(scale[index]);
+        Setting.putScale(index);
+    }
+
+    private void onSpeed(View view) {
+        SpeedDialog.create(this).show();
+    }
+
+    @Override
+    public void setSpeed(float speed) {
+        mBinding.speedText.setText(format.format(speed));
+        Setting.putSpeed(speed);
+    }
+
+    private void onBuffer(View view) {
+        BufferDialog.create(this).show();
+    }
+
+    @Override
+    public void setBuffer(int times) {
+        mBinding.bufferText.setText(String.valueOf(times));
+        Setting.putBuffer(times);
+    }
+
+    private void setRender(View view) {
+        if (Setting.isTunnel() && Setting.getRender() == 0) setTunnel(view);
+        int index = (Setting.getRender() + 1) % render.length;
+        mBinding.renderText.setText(render[index]);
+        Setting.putRender(index);
+    }
+
+    private void setTunnel(View view) {
+        Setting.putTunnel(!Setting.isTunnel());
+        mBinding.tunnelText.setText(getSwitch(Setting.isTunnel()));
+        if (Setting.isTunnel() && Setting.getRender() == 1) setRender(view);
+    }
+
+    private void setCaption(View view) {
+        Setting.putCaption(!Setting.isCaption());
+        mBinding.captionText.setText(caption[Setting.isCaption() ? 1 : 0]);
+    }
+
+    private void setAdblock(View view) {
+        Setting.putAdblock(!Setting.isAdblock());
+        mBinding.adblockText.setText(getSwitch(Setting.isAdblock()));
+    }
+
+    private boolean onCaption(View view) {
+        if (Setting.isCaption()) startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS));
+        return Setting.isCaption();
+    }
+
+    private void setAudioDecode(View view) {
+        Setting.putAudioPrefer(!Setting.isAudioPrefer());
+        mBinding.audioDecodeText.setText(getSwitch(Setting.isAudioPrefer()));
+    }
+
+    private void setVideoDecode(View view) {
+        Setting.putVideoPrefer(!Setting.isVideoPrefer());
+        mBinding.videoDecodeText.setText(getSwitch(Setting.isVideoPrefer()));
+    }
+
+    private void setDanmakuLoad(View view) {
+        Setting.putDanmakuLoad(!Setting.isDanmakuLoad());
+        mBinding.danmakuLoadText.setText(getSwitch(Setting.isDanmakuLoad()));
+    }
+
+    private void onBackground(View view) {
+        Setting.putBackground(Setting.isBackgroundOn() ? 0 : 1);
+        mBinding.backgroundText.setText(getSwitch(Setting.isBackgroundOn()));
+    }
+}

+ 1455 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java

@@ -0,0 +1,1455 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.text.style.ClickableSpan;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
+import androidx.leanback.widget.OnChildViewHolderSelectedListener;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.media3.common.C;
+import androidx.media3.common.MediaMetadata;
+import androidx.media3.common.Player;
+import androidx.media3.common.VideoSize;
+import androidx.media3.ui.PlayerView;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewbinding.ViewBinding;
+
+import com.bumptech.glide.request.transition.Transition;
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.Constant;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.api.SiteApi;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.bean.Danmaku;
+import com.fongmi.android.tv.bean.Episode;
+import com.fongmi.android.tv.bean.Flag;
+import com.fongmi.android.tv.bean.History;
+import com.fongmi.android.tv.bean.Keep;
+import com.fongmi.android.tv.bean.Parse;
+import com.fongmi.android.tv.bean.Result;
+import com.fongmi.android.tv.bean.Site;
+import com.fongmi.android.tv.bean.Sub;
+import com.fongmi.android.tv.bean.Track;
+import com.fongmi.android.tv.bean.Vod;
+import com.fongmi.android.tv.databinding.ActivityVideoBinding;
+import com.fongmi.android.tv.db.AppDatabase;
+import com.fongmi.android.tv.event.RefreshEvent;
+import com.fongmi.android.tv.impl.CustomTarget;
+import com.fongmi.android.tv.model.SiteViewModel;
+import com.fongmi.android.tv.player.PlayerHelper;
+import com.fongmi.android.tv.player.PlayerManager;
+import com.fongmi.android.tv.service.PlaybackService;
+import com.fongmi.android.tv.ui.adapter.ArrayAdapter;
+import com.fongmi.android.tv.ui.adapter.EpisodeAdapter;
+import com.fongmi.android.tv.ui.adapter.FlagAdapter;
+import com.fongmi.android.tv.ui.adapter.ParseAdapter;
+import com.fongmi.android.tv.ui.adapter.PartAdapter;
+import com.fongmi.android.tv.ui.adapter.QualityAdapter;
+import com.fongmi.android.tv.ui.adapter.QuickAdapter;
+import com.fongmi.android.tv.ui.base.PlaybackActivity;
+import com.fongmi.android.tv.ui.custom.CustomKeyDownVod;
+import com.fongmi.android.tv.ui.custom.CustomMovement;
+import com.fongmi.android.tv.ui.custom.CustomSeekView;
+import com.fongmi.android.tv.ui.dialog.ContentDialog;
+import com.fongmi.android.tv.ui.dialog.DanmakuDialog;
+import com.fongmi.android.tv.ui.dialog.SubtitleDialog;
+import com.fongmi.android.tv.ui.dialog.TrackDialog;
+import com.fongmi.android.tv.utils.Clock;
+import com.fongmi.android.tv.utils.FileChooser;
+import com.fongmi.android.tv.utils.ImgUtil;
+import com.fongmi.android.tv.utils.KeyUtil;
+import com.fongmi.android.tv.utils.Notify;
+import com.fongmi.android.tv.utils.PartUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.fongmi.android.tv.utils.Sniffer;
+import com.fongmi.android.tv.utils.Task;
+import com.fongmi.android.tv.utils.Traffic;
+import com.fongmi.android.tv.utils.Util;
+import com.github.bassaer.library.MDColor;
+
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+public class VideoActivity extends PlaybackActivity implements CustomKeyDownVod.Listener, TrackDialog.Listener, ArrayAdapter.OnClickListener, FlagAdapter.OnClickListener, EpisodeAdapter.OnClickListener, QualityAdapter.OnClickListener, QuickAdapter.OnClickListener, ParseAdapter.OnClickListener, Clock.Callback {
+
+    private ActivityVideoBinding mBinding;
+    private ViewGroup.LayoutParams mFrameParams;
+    private Observer<Result> mObserveDetail;
+    private Observer<Result> mObservePlayer;
+    private Observer<Result> mObserveSearch;
+    private EpisodeAdapter mEpisodeAdapter;
+    private QualityAdapter mQualityAdapter;
+    private ArrayAdapter mArrayAdapter;
+    private ParseAdapter mParseAdapter;
+    private QuickAdapter mQuickAdapter;
+    private FlagAdapter mFlagAdapter;
+    private PartAdapter mPartAdapter;
+    private CustomKeyDownVod mKeyDown;
+    private SiteViewModel mViewModel;
+    private List<String> mBroken;
+    private History mHistory;
+    private boolean fullscreen;
+    private boolean initAuto;
+    private boolean autoMode;
+    private boolean useParse;
+    private Runnable mR1;
+    private Runnable mR2;
+    private Runnable mR3;
+    private Runnable mR4;
+    private Clock mClock;
+    private View mFocus1;
+    private View mFocus2;
+
+    public static void push(FragmentActivity activity, String text) {
+        if (FileChooser.isValid(activity, Uri.parse(text))) file(activity, FileChooser.getPathFromUri(Uri.parse(text)));
+        else start(activity, Sniffer.getUrl(text));
+    }
+
+    public static void file(FragmentActivity activity, String path) {
+        if (TextUtils.isEmpty(path)) return;
+        String name = new File(path).getName();
+        start(activity, SiteApi.PUSH, "file://" + path, name);
+    }
+
+    public static void cast(Activity activity, History history) {
+        start(activity, history.getSiteKey(), history.getVodId(), history.getVodName(), history.getVodPic(), null, false, true);
+    }
+
+    public static void collect(Activity activity, String key, String id, String name, String pic) {
+        start(activity, key, id, name, pic, null, true, false);
+    }
+
+    public static void start(Activity activity, String url) {
+        start(activity, SiteApi.PUSH, url, url);
+    }
+
+    public static void start(Activity activity, String key, String id, String name) {
+        start(activity, key, id, name, null);
+    }
+
+    public static void start(Activity activity, String key, String id, String name, String pic) {
+        start(activity, key, id, name, pic, null);
+    }
+
+    public static void start(Activity activity, String key, String id, String name, String pic, String mark) {
+        start(activity, key, id, name, pic, mark, false, false);
+    }
+
+    public static void start(Activity activity, String key, String id, String name, String pic, String mark, boolean collect, boolean cast) {
+        Intent intent = new Intent(activity, VideoActivity.class);
+        intent.putExtra("collect", collect);
+        intent.putExtra("cast", cast);
+        intent.putExtra("mark", mark);
+        intent.putExtra("name", name);
+        intent.putExtra("pic", pic);
+        intent.putExtra("key", key);
+        intent.putExtra("id", id);
+        activity.startActivity(intent);
+    }
+
+    private boolean isCast() {
+        return getIntent().getBooleanExtra("cast", false);
+    }
+
+    private String getName() {
+        return Objects.toString(getIntent().getStringExtra("name"), "");
+    }
+
+    private String getPic() {
+        return Objects.toString(getIntent().getStringExtra("pic"), "");
+    }
+
+    private String getMark() {
+        return Objects.toString(getIntent().getStringExtra("mark"), "");
+    }
+
+    private String getKey() {
+        return Objects.toString(getIntent().getStringExtra("key"), "");
+    }
+
+    private String getId() {
+        return Objects.toString(getIntent().getStringExtra("id"), "");
+    }
+
+    private String getHistoryKey() {
+        return getKey().concat(AppDatabase.SYMBOL).concat(getId()).concat(AppDatabase.SYMBOL) + VodConfig.getCid();
+    }
+
+    private Site getSite() {
+        return VodConfig.get().getSite(getKey());
+    }
+
+    private Flag getFlag() {
+        return mFlagAdapter.getActivated();
+    }
+
+    private Episode getEpisode() {
+        return mEpisodeAdapter.getActivated();
+    }
+
+    private int getScale() {
+        return mHistory != null && mHistory.getScale() != -1 ? mHistory.getScale() : Setting.getScale();
+    }
+
+    private boolean isReplay() {
+        return Setting.getReset() == 1;
+    }
+
+    private boolean isFromCollect() {
+        return getIntent().getBooleanExtra("collect", false);
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivityVideoBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected PlaybackService.NavigationCallback getNavigationCallback() {
+        return mNavigationCallback;
+    }
+
+    @Override
+    protected PlayerView getExoView() {
+        return mBinding.exo;
+    }
+
+    @Override
+    protected CustomSeekView getSeekView() {
+        return mBinding.control.seek;
+    }
+
+    @Override
+    protected void onServiceConnected() {
+        player().setDanmakuView(mBinding.danmaku);
+        setDanmakuSize();
+        checkId();
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        String oldId = getId();
+        super.onNewIntent(intent);
+        String id = Objects.toString(intent.getStringExtra("id"), "");
+        if (TextUtils.isEmpty(id) || id.equals(oldId)) return;
+        getIntent().putExtras(intent);
+        saveHistory();
+        checkId();
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        super.initView(savedInstanceState);
+        mFrameParams = mBinding.video.getLayoutParams();
+        mClock = Clock.create(mBinding.widget.clock);
+        mKeyDown = CustomKeyDownVod.create(this);
+        mObserveDetail = this::setDetail;
+        mObservePlayer = this::setPlayer;
+        mObserveSearch = this::setSearch;
+        mBroken = new ArrayList<>();
+        mR1 = this::hideControl;
+        mR2 = this::updateFocus;
+        mR3 = this::setTraffic;
+        mR4 = this::showEmpty;
+        setRecyclerView();
+        setVideoView();
+        setViewModel();
+        checkCast();
+    }
+
+    @Override
+    @SuppressLint("ClickableViewAccessibility")
+    protected void initEvent() {
+        mBinding.keep.setOnClickListener(view -> onKeep());
+        mBinding.video.setOnClickListener(view -> onVideo());
+        mBinding.change1.setOnClickListener(view -> onChange());
+        mBinding.content.setOnClickListener(view -> onContent());
+        mBinding.control.action.text.setOnClickListener(this::onTrack);
+        mBinding.control.action.audio.setOnClickListener(this::onTrack);
+        mBinding.control.action.video.setOnClickListener(this::onTrack);
+        mBinding.control.action.speed.setUpListener(this::onSpeedAdd);
+        mBinding.control.action.speed.setDownListener(this::onSpeedSub);
+        mBinding.control.action.ending.setUpListener(this::onEndingAdd);
+        mBinding.control.action.ending.setDownListener(this::onEndingSub);
+        mBinding.control.action.opening.setUpListener(this::onOpeningAdd);
+        mBinding.control.action.opening.setDownListener(this::onOpeningSub);
+        mBinding.control.action.text.setUpListener(this::onSubtitleClick);
+        mBinding.control.action.text.setDownListener(this::onSubtitleClick);
+        mBinding.control.action.loop.setOnClickListener(view -> onLoop());
+        mBinding.control.action.next.setOnClickListener(view -> checkNext());
+        mBinding.control.action.prev.setOnClickListener(view -> checkPrev());
+        mBinding.control.action.scale.setOnClickListener(view -> onScale());
+        mBinding.control.action.speed.setOnClickListener(view -> onSpeed());
+        mBinding.control.action.reset.setOnClickListener(view -> onReset());
+        mBinding.control.action.title.setOnClickListener(view -> onTitle());
+        mBinding.control.action.player.setOnClickListener(view -> onChoose());
+        mBinding.control.action.decode.setOnClickListener(view -> onDecode());
+        mBinding.control.action.ending.setOnClickListener(view -> onEnding());
+        mBinding.control.action.change2.setOnClickListener(view -> onChange());
+        mBinding.control.action.danmaku.setOnClickListener(view -> onDanmaku());
+        mBinding.control.action.opening.setOnClickListener(view -> onOpening());
+        mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong());
+        mBinding.control.action.reset.setOnLongClickListener(view -> onResetToggle());
+        mBinding.control.action.ending.setOnLongClickListener(view -> onEndingReset());
+        mBinding.control.action.opening.setOnLongClickListener(view -> onOpeningReset());
+        mBinding.video.setOnTouchListener((view, event) -> mKeyDown.onTouchEvent(event));
+        mBinding.flag.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() {
+            @Override
+            public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) {
+                if (mFlagAdapter.getItemCount() > 0) onItemClick(mFlagAdapter.get(position));
+            }
+        });
+        mBinding.episode.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() {
+            @Override
+            public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) {
+                if (child != null && mBinding.video != mFocus1) mFocus1 = child.itemView;
+            }
+        });
+        mBinding.array.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() {
+            @Override
+            public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) {
+                if (mEpisodeAdapter.getItemCount() > 20 && position > 1) mBinding.episode.setSelectedPosition((position - 2) * 20);
+            }
+        });
+    }
+
+    private void setRecyclerView() {
+        mBinding.flag.setHorizontalSpacing(ResUtil.dp2px(8));
+        mBinding.flag.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+        mBinding.flag.setAdapter(mFlagAdapter = new FlagAdapter(this));
+        mBinding.episode.setHorizontalSpacing(ResUtil.dp2px(8));
+        mBinding.episode.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+        mBinding.episode.setAdapter(mEpisodeAdapter = new EpisodeAdapter(this));
+        mBinding.quality.setHorizontalSpacing(ResUtil.dp2px(8));
+        mBinding.quality.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+        mBinding.quality.setAdapter(mQualityAdapter = new QualityAdapter(this));
+        mBinding.array.setHorizontalSpacing(ResUtil.dp2px(8));
+        mBinding.array.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+        mBinding.array.setAdapter(mArrayAdapter = new ArrayAdapter(this));
+        mBinding.part.setHorizontalSpacing(ResUtil.dp2px(8));
+        mBinding.part.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+        mBinding.part.setAdapter(mPartAdapter = new PartAdapter(item -> initSearch(item, false)));
+        mBinding.quick.setHorizontalSpacing(ResUtil.dp2px(8));
+        mBinding.quick.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+        mBinding.quick.setAdapter(mQuickAdapter = new QuickAdapter(this));
+        mBinding.control.parse.setHorizontalSpacing(ResUtil.dp2px(8));
+        mBinding.control.parse.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+        mBinding.control.parse.setAdapter(mParseAdapter = new ParseAdapter(this));
+        mParseAdapter.addAll(VodConfig.get().getParses());
+    }
+
+    private void setVideoView() {
+        mBinding.control.action.danmaku.setVisibility(Setting.isDanmakuLoad() ? View.VISIBLE : View.GONE);
+        mBinding.control.action.reset.setText(ResUtil.getStringArray(R.array.select_reset)[Setting.getReset()]);
+    }
+
+    private void setDecode() {
+        mBinding.control.action.decode.setText(player().getDecodeText());
+    }
+
+    private void setScale(int scale) {
+        mHistory.setScale(scale);
+        mBinding.exo.setResizeMode(scale);
+        mBinding.control.action.scale.setText(ResUtil.getStringArray(R.array.select_scale)[scale]);
+    }
+
+    private void setViewModel() {
+        mViewModel = new ViewModelProvider(this).get(SiteViewModel.class);
+        mViewModel.getResult().observeForever(mObserveDetail);
+        mViewModel.getPlayer().observeForever(mObservePlayer);
+        mViewModel.getSearch().observeForever(mObserveSearch);
+    }
+
+    private void checkCast() {
+        if (isCast() && !isFullscreen()) enterFullscreen();
+        else mBinding.progressLayout.showProgress();
+    }
+
+    private void checkId() {
+        if (getId().startsWith("push://")) getIntent().putExtra("key", SiteApi.PUSH).putExtra("id", getId().substring(7));
+        if (getId().isEmpty() || getId().startsWith("msearch:")) setEmpty(false);
+        else getDetail();
+    }
+
+    private void getDetail() {
+        mViewModel.detailContent(getKey(), getId());
+    }
+
+    private void getDetail(Vod item) {
+        getIntent().putExtra("key", item.getSiteKey());
+        getIntent().putExtra("pic", item.getPic());
+        getIntent().putExtra("id", item.getId());
+        mBinding.scroll.scrollTo(0, 0);
+        mClock.setCallback(null);
+        player().reset();
+        player().stop();
+        saveHistory();
+        getDetail();
+    }
+
+    private void setDetail(Result result) {
+        if (result.getList().isEmpty()) setEmpty(result.hasMsg());
+        else setDetail(result.getVod());
+        Notify.show(result.getMsg());
+    }
+
+    private void setEmpty(boolean finish) {
+        if (isFromCollect() || finish) {
+            finish();
+        } else if (getName().isEmpty()) {
+            showEmpty();
+        } else {
+            mBinding.name.setText(getName());
+            App.post(mR4, 10000);
+            checkSearch(false);
+        }
+    }
+
+    private void showEmpty() {
+        mBinding.progressLayout.showEmpty();
+    }
+
+    private void setDetail(Vod item) {
+        item.checkPic(getPic());
+        item.checkName(getName());
+        mBinding.progressLayout.showContent();
+        mBinding.name.setText(item.getName());
+        mFlagAdapter.addAll(item.getFlags());
+        mBinding.video.requestFocus();
+        App.removeCallbacks(mR4);
+        checkHistory(item);
+        checkFlag(item);
+        checkKeepImg();
+        setText(item);
+        updateKeep();
+    }
+
+    private void setText(Vod item) {
+        mBinding.content.setTag(item.getContent());
+        setText(mBinding.year, R.string.detail_year, item.getYear());
+        setText(mBinding.area, R.string.detail_area, item.getArea());
+        setText(mBinding.type, R.string.detail_type, item.getTypeName());
+        setText(mBinding.site, R.string.detail_site, getSite().getName());
+        setText(mBinding.director, R.string.detail_director, item.getDirector());
+        setText(mBinding.actor, R.string.detail_actor, item.getActor());
+        setText(mBinding.remark, 0, item.getRemarks());
+    }
+
+    private void setText(TextView view, int resId, String text) {
+        if (TextUtils.isEmpty(text) && !TextUtils.isEmpty(view.getText())) return;
+        view.setText(Sniffer.buildClickable(resId > 0 ? getString(resId, text) : text, this::clickableSpan), TextView.BufferType.SPANNABLE);
+        view.setVisibility(text.isEmpty() ? View.GONE : View.VISIBLE);
+        view.setLinkTextColor(MDColor.YELLOW_500);
+        CustomMovement.bind(view);
+    }
+
+    private ClickableSpan clickableSpan(Result result) {
+        return new ClickableSpan() {
+            @Override
+            public void onClick(@NonNull View view) {
+                VodActivity.start(getActivity(), getKey(), result);
+                setRedirect(true);
+            }
+        };
+    }
+
+    private void getPlayer(Flag flag, Episode episode) {
+        mBinding.widget.title.setText(getString(R.string.detail_title, mBinding.name.getText(), episode.getName()));
+        mViewModel.playerContent(getKey(), flag.getFlag(), episode.getUrl());
+        mBinding.widget.title.setSelected(true);
+        updateHistory(episode);
+        showProgress();
+    }
+
+    private void setPlayer(Result result) {
+        mQualityAdapter.addAll(result);
+        setUseParse(result.shouldUseParse());
+        setQualityVisible(result.getUrl().isMulti());
+        result.getUrl().set(mQualityAdapter.getPosition());
+        if (result.hasArtwork()) setArtwork(result.getArtwork());
+        if (result.hasDesc()) mBinding.content.setTag(result.getDesc());
+        if (result.hasPosition()) mHistory.setPosition(result.getPosition());
+        mBinding.control.parse.setVisibility(isUseParse() ? View.VISIBLE : View.GONE);
+        startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata());
+    }
+
+    @Override
+    public void onItemClick(Flag item) {
+        if (mFlagAdapter.getItemCount() == 0 || item.isActivated()) return;
+        mFlagAdapter.setActivated(item);
+        mBinding.flag.setSelectedPosition(mFlagAdapter.indexOf(item));
+        notifyItemChanged(mBinding.flag, mFlagAdapter);
+        setEpisodeAdapter(item.getEpisodes());
+        setQualityVisible(false);
+        seamless(item);
+    }
+
+    private void setEpisodeAdapter(List<Episode> items) {
+        mBinding.episode.setVisibility(items.isEmpty() ? View.GONE : View.VISIBLE);
+        mEpisodeAdapter.addAll(items);
+        setArrayAdapter(items.size());
+        setR2Callback();
+    }
+
+    private void seamless(Flag flag) {
+        Episode episode = flag.find(mHistory.getVodRemarks(), getMark().isEmpty());
+        setQualityVisible(episode != null && episode.isActivated() && mQualityAdapter.getItemCount() > 1);
+        if (episode == null || episode.isActivated()) return;
+        mHistory.setVodRemarks(episode.getName());
+        onItemClick(episode);
+    }
+
+    @Override
+    public void onItemClick(Episode item) {
+        if (shouldEnterFullscreen(item)) return;
+        mFlagAdapter.toggle(item);
+        notifyItemChanged(mBinding.episode, mEpisodeAdapter);
+        mBinding.episode.setSelectedPosition(mEpisodeAdapter.getPosition());
+        if (isFullscreen()) Notify.show(getString(R.string.play_ready, item.getName()));
+        onRefresh();
+    }
+
+    private void setQualityVisible(boolean visible) {
+        mBinding.quality.setVisibility(visible ? View.VISIBLE : View.GONE);
+        setR2Callback();
+    }
+
+    @Override
+    public void onItemClick(Result result) {
+        startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata());
+    }
+
+    private void reverseEpisode(boolean scroll) {
+        mFlagAdapter.reverse();
+        setEpisodeAdapter(getFlag().getEpisodes());
+        if (scroll) mBinding.episode.setSelectedPosition(mEpisodeAdapter.getPosition());
+    }
+
+    @Override
+    public void onItemClick(Parse item) {
+        setParse(item);
+        onRefresh();
+    }
+
+    private void setParse(Parse item) {
+        VodConfig.get().setParse(item);
+        notifyItemChanged(mBinding.control.parse, mParseAdapter);
+    }
+
+    private void setArrayAdapter(int size) {
+        List<String> items = new ArrayList<>();
+        items.add(getString(R.string.play_reverse));
+        items.add(getString(mHistory.getRevPlayText()));
+        mBinding.array.setVisibility(size > 1 ? View.VISIBLE : View.GONE);
+        if (mHistory.isRevSort()) for (int i = size; i > 0; i -= 20) items.add(i + "-" + Math.max(i - 19, 1));
+        else for (int i = 0; i < size; i += 20) items.add((i + 1) + "-" + Math.min(i + 20, size));
+        mArrayAdapter.addAll(items);
+    }
+
+    private int findFocusDown(int index) {
+        List<Integer> orders = Arrays.asList(R.id.flag, R.id.quality, R.id.episode, R.id.array, R.id.part, R.id.quick);
+        for (int i = 0; i < orders.size(); i++) if (i > index) if (isVisible(findViewById(orders.get(i)))) return orders.get(i);
+        return 0;
+    }
+
+    private int findFocusUp(int index) {
+        List<Integer> orders = Arrays.asList(R.id.flag, R.id.quality, R.id.episode, R.id.array, R.id.part, R.id.quick);
+        for (int i = orders.size() - 1; i >= 0; i--) if (i < index) if (isVisible(findViewById(orders.get(i)))) return orders.get(i);
+        return 0;
+    }
+
+    private void updateFocus() {
+        mPartAdapter.setNextFocusUp(findFocusUp(4));
+        mEpisodeAdapter.setNextFocusUp(findFocusUp(2));
+        mFlagAdapter.setNextFocusDown(findFocusDown(0));
+        mEpisodeAdapter.setNextFocusDown(findFocusDown(2));
+        notifyItemChanged(mBinding.episode, mEpisodeAdapter);
+        notifyItemChanged(mBinding.part, mPartAdapter);
+        notifyItemChanged(mBinding.flag, mFlagAdapter);
+    }
+
+    @Override
+    public void onRevSort() {
+        mHistory.setRevSort(!mHistory.isRevSort());
+        reverseEpisode(false);
+    }
+
+    @Override
+    public void onRevPlay(TextView view) {
+        mHistory.setRevPlay(!mHistory.isRevPlay());
+        view.setText(mHistory.getRevPlayText());
+        Notify.show(mHistory.getRevPlayHint());
+    }
+
+    private boolean shouldEnterFullscreen(Episode item) {
+        boolean enter = !isFullscreen() && item.isActivated();
+        if (enter) enterFullscreen();
+        return enter;
+    }
+
+    private void enterFullscreen() {
+        mFocus1 = getCurrentFocus();
+        mBinding.video.requestFocus();
+        mBinding.video.setForeground(null);
+        mBinding.video.setLayoutParams(new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT));
+        mBinding.flag.setSelectedPosition(mFlagAdapter.getPosition());
+        mKeyDown.setFull(true);
+        setFullscreen(true);
+        setDanmakuSize();
+        mFocus2 = null;
+    }
+
+    private void exitFullscreen() {
+        mBinding.video.setForeground(ResUtil.getDrawable(R.drawable.selector_video));
+        mBinding.video.setLayoutParams(mFrameParams);
+        getFocus1().requestFocus();
+        mKeyDown.setFull(false);
+        setFullscreen(false);
+        setDanmakuSize();
+        mFocus2 = null;
+        hideInfo();
+    }
+
+    private void setDanmakuSize() {
+        if (service() == null) return;
+        player().setDanmakuSize(isFullscreen() ? 1.2f : 0.8f);
+    }
+
+    private void onContent() {
+        if (mBinding.content.getTag() == null) return;
+        ContentDialog.show(this, mBinding.content.getTag().toString());
+    }
+
+    private void onKeep() {
+        Keep keep = Keep.find(getHistoryKey());
+        Notify.show(keep != null ? R.string.keep_del : R.string.keep_add);
+        if (keep != null) keep.delete();
+        else createKeep();
+        checkKeepImg();
+    }
+
+    private void onVideo() {
+        if (!isFullscreen()) enterFullscreen();
+    }
+
+    private void onChange() {
+        checkSearch(true);
+    }
+
+    private void onLoop() {
+        mBinding.control.action.loop.setActivated(!mBinding.control.action.loop.isActivated());
+    }
+
+    private void checkNext() {
+        checkNext(true);
+    }
+
+    private void checkNext(boolean notify) {
+        if (mHistory.isRevPlay()) onPrev(notify);
+        else onNext(notify);
+    }
+
+    private void checkPrev() {
+        if (mHistory.isRevPlay()) onNext(true);
+        else onPrev(true);
+    }
+
+    private void onNext(boolean notify) {
+        Episode item = mEpisodeAdapter.getNext();
+        if (!item.isActivated()) onItemClick(item);
+        else if (notify) Notify.show(mHistory.isRevPlay() ? R.string.error_play_prev : R.string.error_play_next);
+    }
+
+    private void onPrev(boolean notify) {
+        Episode item = mEpisodeAdapter.getPrev();
+        if (!item.isActivated()) onItemClick(item);
+        else if (notify) Notify.show(mHistory.isRevPlay() ? R.string.error_play_next : R.string.error_play_prev);
+    }
+
+    private void onScale() {
+        int index = getScale();
+        String[] array = ResUtil.getStringArray(R.array.select_scale);
+        setScale(index == array.length - 1 ? 0 : ++index);
+    }
+
+    private void onSpeed() {
+        mBinding.control.action.speed.setText(player().addSpeed());
+        mHistory.setSpeed(player().getSpeed());
+    }
+
+    private void onSpeedAdd() {
+        mBinding.control.action.speed.setText(player().addSpeed(0.25f));
+        mHistory.setSpeed(player().getSpeed());
+    }
+
+    private void onSpeedSub() {
+        mBinding.control.action.speed.setText(player().subSpeed(0.25f));
+        mHistory.setSpeed(player().getSpeed());
+    }
+
+    private boolean onSpeedLong() {
+        mBinding.control.action.speed.setText(player().toggleSpeed());
+        mHistory.setSpeed(player().getSpeed());
+        return true;
+    }
+
+    private void onReset() {
+        if (isReplay()) onReplay();
+        else onRefresh();
+    }
+
+    private void onReplay() {
+        mHistory.setPosition(C.TIME_UNSET);
+        if (player().isEmpty()) onRefresh();
+        else player().setMediaItem();
+    }
+
+    private void onRefresh() {
+        saveHistory();
+        player().stop();
+        player().clear();
+        mClock.setCallback(null);
+        if (mFlagAdapter.getItemCount() == 0) return;
+        if (mEpisodeAdapter.getItemCount() == 0) return;
+        getPlayer(getFlag(), getEpisode());
+    }
+
+    private boolean onResetToggle() {
+        Setting.putReset(Math.abs(Setting.getReset() - 1));
+        mBinding.control.action.reset.setText(ResUtil.getStringArray(R.array.select_reset)[Setting.getReset()]);
+        return true;
+    }
+
+    private void onOpening() {
+        long position = player().getPosition();
+        long duration = player().getDuration();
+        if (player().canSetOpening(position, duration)) setOpening(position);
+    }
+
+    private void onOpeningAdd() {
+        setOpening(Math.max(0, Math.max(0, mHistory.getOpening()) + 1000));
+    }
+
+    private void onOpeningSub() {
+        setOpening(Math.max(0, Math.max(0, mHistory.getOpening()) - 1000));
+    }
+
+    private boolean onOpeningReset() {
+        setOpening(0);
+        return true;
+    }
+
+    private void setOpening(long opening) {
+        mHistory.setOpening(opening);
+        mBinding.control.action.opening.setText(opening <= 0 ? getString(R.string.play_op) : Util.timeMs(mHistory.getOpening()));
+    }
+
+    private void onEnding() {
+        long position = player().getPosition();
+        long duration = player().getDuration();
+        if (player().canSetEnding(position, duration)) setEnding(duration - position);
+    }
+
+    private void onEndingAdd() {
+        setEnding(Math.max(0, Math.max(0, mHistory.getEnding()) + 1000));
+    }
+
+    private void onEndingSub() {
+        setEnding(Math.max(0, Math.max(0, mHistory.getEnding()) - 1000));
+    }
+
+    private boolean onEndingReset() {
+        setEnding(0);
+        return true;
+    }
+
+    private void setEnding(long ending) {
+        mHistory.setEnding(ending);
+        mBinding.control.action.ending.setText(ending <= 0 ? getString(R.string.play_ed) : Util.timeMs(mHistory.getEnding()));
+    }
+
+    private void onChoose() {
+        PlayerHelper.choose(this, player().getUrl(), player().getHeaders(), player().isVod(), player().getPosition(), mBinding.widget.title.getText());
+        setRedirect(true);
+    }
+
+    private void onDecode() {
+        mClock.setCallback(null);
+        player().toggleDecode();
+        setDecode();
+    }
+
+    private void onTrack(View view) {
+        TrackDialog.create().type(Integer.parseInt(view.getTag().toString())).player(player()).show(this);
+        hideControl();
+    }
+
+    private void onTitle() {
+        hideControl();
+    }
+
+    private void onDanmaku() {
+        DanmakuDialog.create().player(player()).show(this);
+        hideControl();
+    }
+
+    private void onToggle() {
+        if (isVisible(mBinding.control.getRoot())) hideControl();
+        else showControl(getFocus2());
+    }
+
+    private void showProgress() {
+        mBinding.progress.getRoot().setVisibility(View.VISIBLE);
+        App.post(mR3, 0);
+        hideCenter();
+        hideError();
+    }
+
+    private void hideProgress() {
+        mBinding.progress.getRoot().setVisibility(View.GONE);
+        App.removeCallbacks(mR3);
+        Traffic.reset();
+    }
+
+    private void showError(String text) {
+        mBinding.widget.error.setVisibility(View.VISIBLE);
+        mBinding.widget.text.setText(text);
+        hideProgress();
+    }
+
+    private void hideError() {
+        mBinding.widget.error.setVisibility(View.GONE);
+        mBinding.widget.text.setText("");
+    }
+
+    private void showInfo() {
+        mBinding.widget.top.setVisibility(View.VISIBLE);
+        mBinding.widget.center.setVisibility(View.VISIBLE);
+        mBinding.widget.duration.setText(player().getDurationTime());
+        mBinding.widget.position.setText(player().getPositionTime(0));
+    }
+
+    private void hideInfo() {
+        mBinding.widget.top.setVisibility(View.GONE);
+        mBinding.widget.center.setVisibility(View.GONE);
+    }
+
+    private void showControl(View view) {
+        mBinding.control.getRoot().setVisibility(View.VISIBLE);
+        view.requestFocus();
+        setR1Callback();
+    }
+
+    private void hideControl() {
+        mBinding.control.getRoot().setVisibility(View.GONE);
+        App.removeCallbacks(mR1);
+    }
+
+    private void hideCenter() {
+        mBinding.widget.action.setImageResource(R.drawable.ic_widget_play);
+        hideInfo();
+    }
+
+    private void setTraffic() {
+        Traffic.setSpeed(mBinding.progress.traffic);
+        App.post(mR3, 1000);
+    }
+
+    private void setR1Callback() {
+        App.post(mR1, Constant.INTERVAL_HIDE);
+    }
+
+    private void setR2Callback() {
+        App.post(mR2, 500);
+    }
+
+    private void setArtwork(String url) {
+        mHistory.setVodPic(url);
+        setArtwork();
+    }
+
+    private void setArtwork() {
+        ImgUtil.load(this, mHistory.getVodPic(), new CustomTarget<>() {
+            @Override
+            public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
+                mBinding.exo.setDefaultArtwork(resource);
+            }
+
+            @Override
+            public void onLoadFailed(@Nullable Drawable errorDrawable) {
+                mBinding.exo.setDefaultArtwork(errorDrawable);
+            }
+        });
+    }
+
+    private void setPartAdapter() {
+        mPartAdapter.addAll(PartUtil.split(mHistory.getVodName()));
+        mBinding.part.setVisibility(View.VISIBLE);
+        setR2Callback();
+    }
+
+    private void checkFlag(Vod item) {
+        boolean empty = item.getFlags().isEmpty();
+        mBinding.flag.setVisibility(empty ? View.GONE : View.VISIBLE);
+        if (empty) {
+            startFlow();
+        } else {
+            onItemClick(mHistory.getFlag());
+            if (mHistory.isRevSort()) reverseEpisode(true);
+        }
+    }
+
+    private void checkHistory(Vod item) {
+        mHistory = History.find(getHistoryKey());
+        mHistory = mHistory == null ? createHistory(item) : mHistory;
+        if (!TextUtils.isEmpty(getMark())) mHistory.setVodRemarks(getMark());
+        if (Setting.isIncognito() && mHistory.getKey().equals(getHistoryKey())) mHistory.delete();
+        mBinding.control.action.opening.setText(mHistory.getOpening() <= 0 ? getString(R.string.play_op) : Util.timeMs(mHistory.getOpening()));
+        mBinding.control.action.ending.setText(mHistory.getEnding() <= 0 ? getString(R.string.play_ed) : Util.timeMs(mHistory.getEnding()));
+        mBinding.control.action.speed.setText(player().setSpeed(mHistory.getSpeed()));
+        mHistory.setVodName(item.getName());
+        setArtwork(item.getPic());
+        setScale(getScale());
+        setPartAdapter();
+    }
+
+    private History createHistory(Vod item) {
+        History history = new History();
+        history.setKey(getHistoryKey());
+        history.setCid(VodConfig.getCid());
+        history.setVodName(item.getName());
+        history.findEpisode(item.getFlags());
+        return history;
+    }
+
+    private void saveHistory() {
+        saveHistory(false);
+    }
+
+    private void saveHistory(boolean exit) {
+        if (mHistory != null && mHistory.canSave() && !Setting.isIncognito()) Task.execute(() -> {
+            mHistory.merge().save();
+            if (exit) RefreshEvent.history();
+        });
+    }
+
+    private void syncHistory() {
+        if (mHistory != null && !Setting.isIncognito()) Task.execute(() -> mHistory.save());
+    }
+
+    private void updateHistory(Episode item) {
+        mHistory.setPosition(item.matchesName(mHistory.getEpisode()) ? mHistory.getPosition() : C.TIME_UNSET);
+        mHistory.setVodFlag(getFlag().getFlag());
+        mHistory.setVodRemarks(item.getName());
+        mHistory.setEpisodeUrl(item.getUrl());
+    }
+
+    private void checkKeepImg() {
+        mBinding.keep.setCompoundDrawablesWithIntrinsicBounds(Keep.find(getHistoryKey()) == null ? R.drawable.ic_detail_keep_off : R.drawable.ic_detail_keep_on, 0, 0, 0);
+    }
+
+    private void createKeep() {
+        Keep keep = new Keep();
+        keep.setKey(getHistoryKey());
+        keep.setCid(VodConfig.getCid());
+        keep.setVodPic(mHistory.getVodPic());
+        keep.setVodName(mHistory.getVodName());
+        keep.setSiteName(getSite().getName());
+        keep.setCreateTime(System.currentTimeMillis());
+        keep.save();
+    }
+
+    private void updateKeep() {
+        Keep keep = Keep.find(getHistoryKey());
+        if (keep != null) {
+            keep.setVodName(mHistory.getVodName());
+            keep.setVodPic(mHistory.getVodPic());
+            keep.save();
+        }
+    }
+
+    private void updateVod(Vod item) {
+        boolean id = !item.getId().isEmpty();
+        boolean pic = !item.getPic().isEmpty();
+        boolean name = !item.getName().isEmpty();
+        if (id) getIntent().putExtra("id", item.getId());
+        if (id) mHistory.replace(getHistoryKey());
+        if (name) mHistory.setVodName(item.getName());
+        if (name) mBinding.name.setText(item.getName());
+        if (name) mBinding.widget.title.setText(item.getName());
+        updateFlag(getFlag(), item.getFlags());
+        if (pic) setArtwork(item.getPic());
+        if (pic || name) setMetadata();
+        if (pic || name) syncHistory();
+        if (pic || name) updateKeep();
+        if (name) setPartAdapter();
+        setText(item);
+    }
+
+    private void updateFlag(Flag activated, List<Flag> items) {
+        items.forEach(item -> mFlagAdapter.getItems().stream()
+                .filter(item::equals).findFirst().ifPresentOrElse(target -> {
+                    target.mergeEpisodes(item.getEpisodes(), mHistory.isRevSort());
+                    if (target.equals(activated)) setEpisodeAdapter(target.getEpisodes());
+                }, () -> mFlagAdapter.add(item)));
+    }
+
+    private final PlaybackService.NavigationCallback mNavigationCallback = new PlaybackService.NavigationCallback() {
+        @Override
+        public void onNext() {
+            checkNext();
+        }
+
+        @Override
+        public void onPrev() {
+            checkPrev();
+        }
+
+        @Override
+        public void onStop() {
+            finish();
+        }
+
+        @Override
+        public void onLoop() {
+            VideoActivity.this.onLoop();
+        }
+
+        @Override
+        public void onReplay() {
+            VideoActivity.this.onReplay();
+        }
+    };
+
+    @Override
+    protected String getPlaybackKey() {
+        return getHistoryKey();
+    }
+
+    @Override
+    protected void onPrepare() {
+        setDecode();
+        setPosition();
+    }
+
+    @Override
+    protected void onTracksChanged() {
+        setTrackVisible();
+        mClock.setCallback(this);
+    }
+
+    @Override
+    protected void onTitlesChanged() {
+        setTitleVisible();
+    }
+
+    @Override
+    protected void onError(String msg) {
+        Track.delete(player().getKey());
+        mClock.setCallback(null);
+        player().resetTrack();
+        player().reset();
+        player().stop();
+        showError(msg);
+        startFlow();
+    }
+
+    @Override
+    protected void onReclaim() {
+        Result result = mViewModel.getPlayer().getValue();
+        if (result != null) setPlayer(result);
+    }
+
+    @Override
+    protected void onStateChanged(int state) {
+        switch (state) {
+            case Player.STATE_BUFFERING:
+                showProgress();
+                break;
+            case Player.STATE_READY:
+                hideProgress();
+                player().reset();
+                break;
+            case Player.STATE_ENDED:
+                checkEnded(true);
+                break;
+        }
+    }
+
+    @Override
+    protected void onPlayingChanged(boolean isPlaying) {
+        if (isPlaying) {
+            hideCenter();
+        } else if (isPaused()) {
+            if (isFullscreen()) showInfo();
+            else hideInfo();
+        }
+    }
+
+    @Override
+    protected void onSizeChanged(VideoSize size) {
+        mBinding.widget.size.setText(player().getSizeText());
+    }
+
+    @Override
+    public void onSubtitleClick() {
+        SubtitleDialog.create().view(mBinding.exo.getSubtitleView()).full(isFullscreen()).show(this);
+        App.post(this::hideControl, 100);
+    }
+
+    @Override
+    public void onTimeChanged(long time) {
+        if (!isOwner()) return;
+        long position, duration;
+        mHistory.setCreateTime(time);
+        mHistory.setPosition(position = player().getPosition());
+        mHistory.setDuration(duration = player().getDuration());
+        if (mHistory.canSave() && mHistory.canSync()) syncHistory();
+        if (mHistory.getEnding() > 0 && duration > 0 && mHistory.getEnding() + position >= duration) {
+            checkEnded(false);
+        }
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onRefreshEvent(RefreshEvent event) {
+        if (isRedirect()) return;
+        if (event.getType() == RefreshEvent.Type.DETAIL) getDetail();
+        else if (event.getType() == RefreshEvent.Type.PLAYER) onRefresh();
+        else if (event.getType() == RefreshEvent.Type.VOD) updateVod(event.getVod());
+        else if (event.getType() == RefreshEvent.Type.SUBTITLE) player().setSub(Sub.from(event.getPath()));
+        else if (event.getType() == RefreshEvent.Type.DANMAKU) player().setDanmaku(Danmaku.from(event.getPath()));
+    }
+
+    private void setPosition() {
+        if (mHistory != null) player().seekTo(Math.max(mHistory.getOpening(), mHistory.getPosition()));
+    }
+
+    private void checkEnded(boolean notify) {
+        if (mBinding.control.action.loop.isActivated()) {
+            onReplay();
+        } else {
+            checkNext(notify);
+        }
+    }
+
+    private void setTrackVisible() {
+        mBinding.control.action.text.setVisibility(player().haveTrack(C.TRACK_TYPE_TEXT) || player().isVod() ? View.VISIBLE : View.GONE);
+        mBinding.control.action.audio.setVisibility(player().haveTrack(C.TRACK_TYPE_AUDIO) ? View.VISIBLE : View.GONE);
+        mBinding.control.action.video.setVisibility(player().haveTrack(C.TRACK_TYPE_VIDEO) ? View.VISIBLE : View.GONE);
+    }
+
+    private void setTitleVisible() {
+        mBinding.control.action.title.setVisibility(View.GONE);
+    }
+
+    private MediaMetadata buildMetadata() {
+        String title = mHistory.getVodName();
+        String episode = getEpisode().getName();
+        boolean empty = episode.isEmpty() || title.equals(episode);
+        String artist = empty ? "" : episode;
+        return PlayerManager.buildMetadata(title, artist, mHistory.getVodPic());
+    }
+
+    private void setMetadata() {
+        player().setMetadata(buildMetadata());
+    }
+
+    private void startFlow() {
+        if (!getSite().isChangeable()) return;
+        if (isUseParse()) checkParse();
+        else checkFlag();
+    }
+
+    private void checkParse() {
+        int position = mParseAdapter.getPosition();
+        boolean last = position == mParseAdapter.getItemCount() - 1;
+        boolean pass = position == 0 || last;
+        if (last) initParse();
+        if (pass) checkFlag();
+        else nextParse(position);
+    }
+
+    private void initParse() {
+        if (mParseAdapter.getItemCount() == 0) return;
+        setParse(mParseAdapter.first());
+    }
+
+    private void checkFlag() {
+        int position = isGone(mBinding.flag) ? -1 : mFlagAdapter.getPosition();
+        if (position == mFlagAdapter.getItemCount() - 1) checkSearch(false);
+        else nextFlag(position);
+    }
+
+    private void checkSearch(boolean force) {
+        if (mQuickAdapter.getItemCount() == 0) initSearch(mBinding.name.getText().toString(), true);
+        else if (isAutoMode() || force) nextSite();
+    }
+
+    private void initSearch(String keyword, boolean auto) {
+        setAutoMode(auto);
+        setInitAuto(auto);
+        startSearch(keyword);
+        mBinding.part.setTag(keyword);
+    }
+
+    private boolean isPass(Site item) {
+        if (isAutoMode() && !item.isChangeable()) return false;
+        return item.isSearchable();
+    }
+
+    private void startSearch(String keyword) {
+        mQuickAdapter.clear();
+        List<Site> sites = new ArrayList<>();
+        for (Site site : VodConfig.get().getSites()) if (isPass(site)) sites.add(site);
+        mViewModel.searchContent(sites, keyword, true);
+    }
+
+    private void setSearch(Result result) {
+        List<Vod> items = result.getList();
+        items.removeIf(this::mismatch);
+        mQuickAdapter.addAll(items);
+        mBinding.quick.setVisibility(View.VISIBLE);
+        if (isInitAuto()) nextSite();
+        if (items.isEmpty()) return;
+        App.removeCallbacks(mR4);
+    }
+
+    @Override
+    public void onItemClick(Vod item) {
+        setAutoMode(false);
+        getDetail(item);
+    }
+
+    private boolean mismatch(Vod item) {
+        if (getId().equals(item.getId())) return true;
+        if (mBroken.contains(item.getId())) return true;
+        String keyword = Objects.toString(mBinding.part.getTag(), "");
+        if (isAutoMode()) return !item.getName().equals(keyword);
+        else return !item.getName().contains(keyword);
+    }
+
+    private void nextParse(int position) {
+        Parse parse = mParseAdapter.get(position + 1);
+        Notify.show(getString(R.string.play_switch_parse, parse.getName()));
+        onItemClick(parse);
+    }
+
+    private void nextFlag(int position) {
+        Flag flag = mFlagAdapter.get(position + 1);
+        Notify.show(getString(R.string.play_switch_flag, flag.getFlag()));
+        onItemClick(flag);
+    }
+
+    private void nextSite() {
+        if (mQuickAdapter.getItemCount() == 0) return;
+        Vod item = mQuickAdapter.get(0);
+        Notify.show(getString(R.string.play_switch_site, item.getSiteName()));
+        mQuickAdapter.remove(0);
+        mBroken.add(getId());
+        setInitAuto(false);
+        getDetail(item);
+    }
+
+    private void onPaused() {
+        controller().pause();
+    }
+
+    private void onPlay() {
+        if (mHistory != null && isEnded()) controller().seekTo(mHistory.getOpening());
+        if (!player().isEmpty() && isIdle()) controller().prepare();
+        controller().play();
+    }
+
+    private boolean onSeekBack() {
+        controller().seekBack();
+        return true;
+    }
+
+    private boolean onSeekForward() {
+        controller().seekForward();
+        return true;
+    }
+
+    private boolean isFullscreen() {
+        return fullscreen;
+    }
+
+    private void setFullscreen(boolean fullscreen) {
+        this.fullscreen = fullscreen;
+    }
+
+    private boolean isInitAuto() {
+        return initAuto;
+    }
+
+    private void setInitAuto(boolean initAuto) {
+        this.initAuto = initAuto;
+    }
+
+    private boolean isAutoMode() {
+        return autoMode;
+    }
+
+    private void setAutoMode(boolean autoMode) {
+        this.autoMode = autoMode;
+    }
+
+    public boolean isUseParse() {
+        return useParse;
+    }
+
+    public void setUseParse(boolean useParse) {
+        this.useParse = useParse;
+    }
+
+    private View getFocus1() {
+        return mFocus1 == null || mFocus1.getVisibility() != View.VISIBLE ? mBinding.video : mFocus1;
+    }
+
+    private View getFocus2() {
+        return mFocus2 == null || mFocus2.getVisibility() != View.VISIBLE || mFocus2 == mBinding.control.action.opening || mFocus2 == mBinding.control.action.ending ? mBinding.control.action.next : mFocus2;
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (isFullscreen() && KeyUtil.isMenuKey(event)) onToggle();
+        if (isVisible(mBinding.control.getRoot())) setR1Callback();
+        if (isVisible(mBinding.control.getRoot())) mFocus2 = getCurrentFocus();
+        if (isFullscreen() && isGone(mBinding.control.getRoot()) && mKeyDown.hasEvent(event) && service() != null) return mKeyDown.onKeyDown(event);
+        if (KeyUtil.isMediaFastForward(event)) return onSeekForward();
+        if (KeyUtil.isMediaRewind(event)) return onSeekBack();
+        return super.dispatchKeyEvent(event);
+    }
+
+    @Override
+    public void onSeeking(long time) {
+        mBinding.widget.center.setVisibility(View.VISIBLE);
+        mBinding.widget.duration.setText(player().getDurationTime());
+        mBinding.widget.position.setText(player().getPositionTime(time));
+        mBinding.widget.action.setImageResource(time > 0 ? R.drawable.ic_widget_forward : R.drawable.ic_widget_rewind);
+        hideProgress();
+    }
+
+    @Override
+    public void onSeekEnd(long time) {
+        mKeyDown.reset();
+        seekTo(time);
+    }
+
+    @Override
+    public void onSpeedUp() {
+        if (!player().isPlaying()) return;
+        mBinding.widget.speed.setVisibility(View.VISIBLE);
+        mBinding.widget.speed.startAnimation(ResUtil.getAnim(R.anim.forward));
+        mBinding.control.action.speed.setText(player().setSpeed(Setting.getSpeed()));
+    }
+
+    @Override
+    public void onSpeedEnd() {
+        mBinding.widget.speed.clearAnimation();
+        mBinding.widget.speed.setVisibility(View.GONE);
+        mBinding.control.action.speed.setText(player().setSpeed(mHistory.getSpeed()));
+    }
+
+    @Override
+    public void onKeyUp() {
+        long position = player().getPosition();
+        long duration = player().getDuration();
+        if (player().canSetOpening(position, duration)) {
+            showControl(mBinding.control.action.opening);
+        } else if (player().canSetEnding(position, duration)) {
+            showControl(mBinding.control.action.ending);
+        } else {
+            showControl(getFocus2());
+        }
+    }
+
+    @Override
+    public void onKeyDown() {
+        showControl(getFocus2());
+    }
+
+    @Override
+    public void onKeyCenter() {
+        if (player().isPlaying()) onPaused();
+        else if (player().isEmpty()) onRefresh();
+        else onPlay();
+        hideControl();
+    }
+
+    @Override
+    public void onSingleTap() {
+        if (isFullscreen()) onToggle();
+    }
+
+    @Override
+    public void onDoubleTap() {
+        if (isFullscreen()) onKeyCenter();
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+        super.onActivityResult(requestCode, resultCode, data);
+        if (resultCode == RESULT_OK && requestCode == 1001) PlayerHelper.onExternalResult(data, service()::dispatchNext, controller()::seekTo);
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        mClock.stop().start();
+    }
+
+    @Override
+    protected void onStop() {
+        super.onStop();
+        if (Setting.isBackgroundOff()) mClock.stop();
+    }
+
+    @Override
+    protected void onBackInvoked() {
+        if (isVisible(mBinding.control.getRoot())) {
+            hideControl();
+        } else if (isVisible(mBinding.widget.center)) {
+            hideCenter();
+        } else if (isFullscreen()) {
+            exitFullscreen();
+        } else {
+            mViewModel.stopSearch();
+            if (isTaskRoot()) startActivity(new Intent(this, HomeActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP));
+            super.onBackInvoked();
+        }
+    }
+
+    @Override
+    protected void onDestroy() {
+        mClock.release();
+        saveHistory(true);
+        RefreshEvent.keep();
+        App.removeCallbacks(mR1, mR2, mR3, mR4);
+        mViewModel.getResult().removeObserver(mObserveDetail);
+        mViewModel.getPlayer().removeObserver(mObservePlayer);
+        mViewModel.getSearch().removeObserver(mObserveSearch);
+        super.onDestroy();
+    }
+}

+ 197 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java

@@ -0,0 +1,197 @@
+package com.fongmi.android.tv.ui.activity;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.leanback.widget.OnChildViewHolderSelectedListener;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewbinding.ViewBinding;
+import androidx.viewpager.widget.ViewPager;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.bean.Class;
+import com.fongmi.android.tv.bean.Result;
+import com.fongmi.android.tv.databinding.ActivityVodBinding;
+import com.fongmi.android.tv.event.RefreshEvent;
+import com.fongmi.android.tv.ui.adapter.TypeAdapter;
+import com.fongmi.android.tv.ui.base.BaseActivity;
+import com.fongmi.android.tv.ui.fragment.FolderFragment;
+import com.fongmi.android.tv.utils.KeyUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+import java.util.Optional;
+
+public class VodActivity extends BaseActivity implements TypeAdapter.OnClickListener {
+
+    private ActivityVodBinding mBinding;
+    private TypeAdapter mAdapter;
+    private View mOldView;
+
+    public static void start(Activity activity, Result result) {
+        start(activity, VodConfig.get().getHome().getKey(), result);
+    }
+
+    public static void start(Activity activity, String key, Result result) {
+        if (result == null || result.getTypes().isEmpty()) return;
+        Intent intent = new Intent(activity, VodActivity.class);
+        intent.putExtra("key", key);
+        intent.putExtra("result", result);
+        activity.startActivity(intent);
+    }
+
+    private String getKey() {
+        return getIntent().getStringExtra("key");
+    }
+
+    private Result getResult() {
+        return getIntent().getParcelableExtra("result");
+    }
+
+    private Class getType() {
+        return mAdapter.get(mBinding.pager.getCurrentItem());
+    }
+
+    private FolderFragment getFragment() {
+        return (FolderFragment) mBinding.pager.getAdapter().instantiateItem(mBinding.pager, mBinding.pager.getCurrentItem());
+    }
+
+    @Override
+    protected ViewBinding getBinding() {
+        return mBinding = ActivityVodBinding.inflate(getLayoutInflater());
+    }
+
+    @Override
+    protected void initView(Bundle savedInstanceState) {
+        setRecyclerView();
+        setTypes();
+        setPager();
+    }
+
+    @Override
+    protected void initEvent() {
+        mBinding.pager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+            @Override
+            public void onPageSelected(int position) {
+                mBinding.recycler.setSelectedPosition(position);
+                mBinding.recycler.requestFocus();
+            }
+        });
+        mBinding.recycler.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() {
+            @Override
+            public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) {
+                onChildSelected(child);
+            }
+        });
+    }
+
+    private void setRecyclerView() {
+        mBinding.recycler.requestFocus();
+        mBinding.recycler.setHorizontalSpacing(ResUtil.dp2px(16));
+        mBinding.recycler.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+        mBinding.recycler.setAdapter(mAdapter = new TypeAdapter(this));
+    }
+
+    private void setTypes() {
+        mAdapter.addAll(getResult().getTypes());
+    }
+
+    private void setPager() {
+        mBinding.pager.setAdapter(new PageAdapter(getSupportFragmentManager()));
+    }
+
+    private void onChildSelected(@Nullable RecyclerView.ViewHolder child) {
+        if (mOldView != null) mOldView.setActivated(false);
+        if ((mOldView = child != null ? child.itemView : null) == null) return;
+        mOldView.setActivated(true);
+        App.post(mRunnable, 100);
+    }
+
+    private final Runnable mRunnable = new Runnable() {
+        @Override
+        public void run() {
+            mBinding.pager.setCurrentItem(mBinding.recycler.getSelectedPosition());
+        }
+    };
+
+    private boolean isFilterVisible() {
+        return Optional.ofNullable(getType()).map(Class::getFilter).orElse(false);
+    }
+
+    private void updateFilter() {
+        Optional.ofNullable(getType()).ifPresent(this::updateFilter);
+    }
+
+    private void updateFilter(Class item) {
+        item.setFilter(!item.getFilter());
+        getFragment().toggleFilter(item.getFilter());
+        mAdapter.notifyItemRangeChanged(mAdapter.indexOf(item), 1);
+    }
+
+    public void closeFilter() {
+        if (isFilterVisible()) updateFilter();
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onRefreshEvent(RefreshEvent event) {
+        if (event.getType() == RefreshEvent.Type.CATEGORY) getFragment().onRefresh();
+    }
+
+    @Override
+    public void onItemClick(Class item) {
+        updateFilter(item);
+    }
+
+    @Override
+    public void onRefresh(Class item) {
+        getFragment().onRefresh();
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (KeyUtil.isMenuKey(event)) updateFilter();
+        return super.dispatchKeyEvent(event);
+    }
+
+    @Override
+    protected void onBackInvoked() {
+        if (isFilterVisible()) updateFilter();
+        else if (getFragment().canBack()) getFragment().goBack();
+        else super.onBackInvoked();
+    }
+
+    class PageAdapter extends FragmentStatePagerAdapter {
+
+        public PageAdapter(@NonNull FragmentManager fm) {
+            super(fm);
+        }
+
+        @NonNull
+        @Override
+        public Fragment getItem(int position) {
+            Class type = mAdapter.get(position);
+            return FolderFragment.newInstance(getKey(), type);
+        }
+
+        @Override
+        public int getCount() {
+            return mAdapter.getItemCount();
+        }
+
+        @Override
+        public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
+        }
+    }
+}

+ 80 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ArrayAdapter.java

@@ -0,0 +1,80 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.databinding.AdapterArrayBinding;
+import com.fongmi.android.tv.utils.ResUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ArrayAdapter extends RecyclerView.Adapter<ArrayAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<String> mItems;
+    private final String backward;
+    private final String forward;
+    private final String reverse;
+
+    public ArrayAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+        forward = ResUtil.getString(R.string.play_forward);
+        reverse = ResUtil.getString(R.string.play_reverse);
+        backward = ResUtil.getString(R.string.play_backward);
+    }
+
+    public void addAll(List<String> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterArrayBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        String text = mItems.get(position);
+        holder.binding.text.setText(text);
+        if (text.equals(reverse)) holder.binding.getRoot().setOnClickListener(view -> mListener.onRevSort());
+        else if (text.equals(backward) || text.equals(forward)) holder.binding.getRoot().setOnClickListener(view -> mListener.onRevPlay(holder.binding.text));
+        else holder.binding.getRoot().setOnClickListener(null);
+    }
+
+    public interface OnClickListener {
+
+        void onRevSort();
+
+        void onRevPlay(TextView view);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterArrayBinding binding;
+
+        ViewHolder(@NonNull AdapterArrayBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 19 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/BaseDiffCallback.java

@@ -0,0 +1,19 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import androidx.annotation.NonNull;
+import androidx.leanback.widget.DiffCallback;
+
+import com.fongmi.android.tv.impl.Diffable;
+
+public class BaseDiffCallback<T extends Diffable<T>> extends DiffCallback<T> {
+
+    @Override
+    public boolean areItemsTheSame(T oldItem, @NonNull T newItem) {
+        return oldItem.isSameItem(newItem);
+    }
+
+    @Override
+    public boolean areContentsTheSame(T oldItem, @NonNull T newItem) {
+        return oldItem.isSameContent(newItem);
+    }
+}

+ 99 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ChannelAdapter.java

@@ -0,0 +1,99 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.bumptech.glide.Glide;
+import com.fongmi.android.tv.bean.Channel;
+import com.fongmi.android.tv.databinding.AdapterChannelBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ChannelAdapter extends RecyclerView.Adapter<ChannelAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<Channel> mItems;
+
+    public ChannelAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+    }
+
+    public void addAll(List<Channel> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    public void remove(Channel item) {
+        int index = mItems.indexOf(item);
+        if (index < 0) return;
+        mItems.remove(index);
+        notifyItemRemoved(index);
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    public Channel get(int position) {
+        return mItems.get(position);
+    }
+
+    public void setSelected(Channel selected) {
+        for (Channel item : mItems) item.setSelected(selected);
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterChannelBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Channel item = mItems.get(position);
+        item.loadLogo(holder.binding.logo);
+        holder.binding.name.setText(item.getShow());
+        holder.binding.number.setText(item.getNumber());
+        holder.binding.getRoot().setSelected(item.isSelected());
+        holder.binding.getRoot().setRightListener(() -> mListener.showEpg(item));
+        holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item));
+        holder.binding.getRoot().setOnLongClickListener(v -> mListener.onLongClick(item));
+    }
+
+    @Override
+    public void onViewRecycled(@NonNull ViewHolder holder) {
+        Glide.with(holder.binding.logo).clear(holder.binding.logo);
+    }
+
+    public interface OnClickListener {
+
+        void showEpg(Channel item);
+
+        void onItemClick(Channel item);
+
+        boolean onLongClick(Channel item);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterChannelBinding binding;
+
+        ViewHolder(@NonNull AdapterChannelBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 63 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java

@@ -0,0 +1,63 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.Collect;
+import com.fongmi.android.tv.databinding.AdapterFilterBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CollectAdapter extends RecyclerView.Adapter<CollectAdapter.ViewHolder> {
+
+    private final List<Collect> mItems;
+
+    public CollectAdapter() {
+        mItems = new ArrayList<>();
+    }
+
+    public void add(Collect item) {
+        mItems.add(item);
+        notifyItemInserted(mItems.size() - 1);
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    public Collect get(int position) {
+        return mItems.get(position);
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterFilterBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Collect item = mItems.get(position);
+        holder.binding.text.setText(item.getSite().getName());
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterFilterBinding binding;
+
+        ViewHolder(@NonNull AdapterFilterBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 81 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java

@@ -0,0 +1,81 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.Config;
+import com.fongmi.android.tv.databinding.AdapterConfigBinding;
+
+import java.util.List;
+
+public class ConfigAdapter extends RecyclerView.Adapter<ConfigAdapter.ViewHolder> {
+
+    private final OnClickListener listener;
+    private List<Config> mItems;
+    private boolean readOnly;
+
+    public ConfigAdapter(OnClickListener listener) {
+        this.listener = listener;
+    }
+
+    public interface OnClickListener {
+
+        void onTextClick(Config item);
+
+        void onDeleteClick(Config item);
+    }
+
+    public ConfigAdapter readOnly(boolean readOnly) {
+        this.readOnly = readOnly;
+        return this;
+    }
+
+    public ConfigAdapter addAll(int type) {
+        mItems = Config.getAll(type);
+        if (!mItems.isEmpty() && !readOnly) mItems.remove(0);
+        return this;
+    }
+
+    public int remove(Config item) {
+        int position = mItems.indexOf(item);
+        if (position == -1) return -1;
+        item.delete();
+        mItems.remove(position);
+        notifyItemRemoved(position);
+        return getItemCount();
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterConfigBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Config item = mItems.get(position);
+        holder.binding.text.setText(item.getDesc());
+        holder.binding.text.setOnClickListener(v -> listener.onTextClick(item));
+        holder.binding.delete.setVisibility(readOnly ? View.GONE : View.VISIBLE);
+        holder.binding.delete.setOnClickListener(v -> listener.onDeleteClick(item));
+    }
+
+    public class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterConfigBinding binding;
+
+        public ViewHolder(@NonNull AdapterConfigBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 67 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/DohAdapter.java

@@ -0,0 +1,67 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.databinding.AdapterDohBinding;
+import com.github.catvod.bean.Doh;
+
+import java.util.List;
+
+public class DohAdapter extends RecyclerView.Adapter<DohAdapter.ViewHolder> {
+
+    private final OnClickListener listener;
+    private final List<Doh> mItems;
+    private int select;
+
+    public DohAdapter(OnClickListener listener) {
+        this.mItems = VodConfig.get().getDoh();
+        this.listener = listener;
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Doh item);
+    }
+
+    public void setSelect(int select) {
+        this.select = select;
+    }
+
+    public int getSelect() {
+        return select;
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterDohBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Doh item = mItems.get(position);
+        holder.binding.text.setText(item.getName());
+        holder.binding.text.setActivated(select == position);
+        holder.binding.text.setOnClickListener(v -> listener.onItemClick(item));
+    }
+
+    public class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterDohBinding binding;
+
+        public ViewHolder(@NonNull AdapterDohBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 84 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpgDataAdapter.java

@@ -0,0 +1,84 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.EpgData;
+import com.fongmi.android.tv.databinding.AdapterEpgDataBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class EpgDataAdapter extends RecyclerView.Adapter<EpgDataAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<EpgData> mItems;
+
+    public EpgDataAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+    }
+
+    public void addAll(List<EpgData> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    public EpgData get(int position) {
+        return mItems.get(position);
+    }
+
+    public void setSelected(EpgData selected) {
+        for (EpgData item : mItems) item.setSelected(selected);
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterEpgDataBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        EpgData item = mItems.get(position);
+        holder.binding.time.setText(item.getTime());
+        holder.binding.title.setText(item.getTitle());
+        holder.binding.getRoot().setSelected(item.isSelected());
+        holder.binding.getRoot().setLeftListener(mListener::hideEpg);
+        holder.binding.getRoot().setOnClickListener(v -> {
+            if (!item.isFuture()) mListener.onItemClick(item);
+        });
+    }
+
+    public interface OnClickListener {
+
+        void hideEpg();
+
+        void onItemClick(EpgData item);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterEpgDataBinding binding;
+
+        ViewHolder(@NonNull AdapterEpgDataBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 109 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java

@@ -0,0 +1,109 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.Episode;
+import com.fongmi.android.tv.databinding.AdapterEpisodeBinding;
+import com.fongmi.android.tv.utils.ResUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class EpisodeAdapter extends RecyclerView.Adapter<EpisodeAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<Episode> mItems;
+    private final int maxWidth;
+    private int nextFocusDown;
+    private int nextFocusUp;
+
+    public EpisodeAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+        maxWidth = ResUtil.getScreenWidth() - ResUtil.dp2px(48);
+    }
+
+    public void addAll(List<Episode> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    public int getPosition() {
+        for (int i = 0; i < mItems.size(); i++) if (mItems.get(i).isActivated()) return i;
+        return 0;
+    }
+
+    public Episode getActivated() {
+        return mItems.isEmpty() ? new Episode() : mItems.get(getPosition());
+    }
+
+    public Episode getNext() {
+        int current = getPosition();
+        int max = getItemCount() - 1;
+        current = ++current > max ? max : current;
+        return mItems.get(current);
+    }
+
+    public Episode getPrev() {
+        int current = getPosition();
+        current = --current < 0 ? 0 : current;
+        return mItems.get(current);
+    }
+
+    public void setNextFocusDown(int nextFocusDown) {
+        this.nextFocusDown = nextFocusDown;
+        notifyDataSetChanged();
+    }
+
+    public void setNextFocusUp(int nextFocusUp) {
+        this.nextFocusUp = nextFocusUp;
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterEpisodeBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Episode item = mItems.get(position);
+        holder.binding.text.setMaxWidth(maxWidth);
+        holder.binding.text.setNextFocusUpId(nextFocusUp);
+        holder.binding.text.setNextFocusDownId(nextFocusDown);
+        holder.binding.text.setActivated(item.isActivated());
+        holder.binding.text.setText(item.getDesc().concat(item.getName()));
+        holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item));
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Episode item);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterEpisodeBinding binding;
+
+        ViewHolder(@NonNull AdapterEpisodeBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 65 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/FileAdapter.java

@@ -0,0 +1,65 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.databinding.AdapterFileBinding;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+public class FileAdapter extends RecyclerView.Adapter<FileAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<File> mItems;
+
+    public FileAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+    }
+
+    public void addAll(List<File> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterFileBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        File file = mItems.get(position);
+        holder.binding.name.setText(file.getName());
+        holder.binding.image.setImageResource(file.isDirectory() ? R.drawable.ic_folder : R.drawable.ic_file);
+        holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(file));
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(File file);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterFileBinding binding;
+
+        ViewHolder(@NonNull AdapterFileBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 123 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/FlagAdapter.java

@@ -0,0 +1,123 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.bean.Episode;
+import com.fongmi.android.tv.bean.Flag;
+import com.fongmi.android.tv.databinding.AdapterFlagBinding;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class FlagAdapter extends RecyclerView.Adapter<FlagAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<Flag> mItems;
+    private int nextFocusDown;
+
+    public FlagAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+        nextFocusDown = R.id.episode;
+    }
+
+    public void addAll(List<Flag> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    public void add(Flag item) {
+        mItems.add(item);
+        notifyItemInserted(mItems.size() - 1);
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    public Flag get(int position) {
+        return mItems.get(position);
+    }
+
+    public List<Flag> getItems() {
+        return mItems;
+    }
+
+    public int indexOf(Flag item) {
+        return mItems.indexOf(item);
+    }
+
+    public int getPosition() {
+        for (int i = 0; i < mItems.size(); i++) if (mItems.get(i).isActivated()) return i;
+        return 0;
+    }
+
+    public Flag getActivated() {
+        return mItems.get(getPosition());
+    }
+
+    public void setActivated(Flag item) {
+        if (indexOf(item) == -1) item.setFlag(mItems.get(0).getFlag());
+        for (Flag flag : mItems) flag.setActivated(item);
+        notifyItemRangeChanged(0, getItemCount());
+    }
+
+    public void toggle(Episode item) {
+        int flagPosition = getPosition();
+        for (int i = 0; i < mItems.size(); i++) mItems.get(i).toggle(flagPosition == i, item);
+        notifyDataSetChanged();
+    }
+
+    public void reverse() {
+        for (Flag flag : mItems) Collections.reverse(flag.getEpisodes());
+        notifyDataSetChanged();
+    }
+
+    public void setNextFocusDown(int nextFocusDown) {
+        this.nextFocusDown = nextFocusDown;
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterFlagBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Flag item = mItems.get(position);
+        holder.binding.text.setText(item.getShow());
+        holder.binding.text.setActivated(item.isActivated());
+        holder.binding.text.setNextFocusDownId(nextFocusDown);
+        holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item));
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Flag item);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterFlagBinding binding;
+
+        ViewHolder(@NonNull AdapterFlagBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 85 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/GroupAdapter.java

@@ -0,0 +1,85 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.Group;
+import com.fongmi.android.tv.databinding.AdapterGroupBinding;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class GroupAdapter extends RecyclerView.Adapter<GroupAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<Group> mItems;
+
+    public GroupAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+    }
+
+    public void addAll(List<Group> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    public void add(int position, Group item) {
+        mItems.add(position, item);
+        notifyItemInserted(position);
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    public Group get(int position) {
+        return mItems.get(position);
+    }
+
+    public int indexOf(Group item) {
+        return mItems.indexOf(item);
+    }
+
+    public List<Group> unmodifiableList() {
+        return Collections.unmodifiableList(mItems);
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterGroupBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Group item = mItems.get(position);
+        holder.binding.name.setText(item.getName());
+        holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item));
+    }
+
+    public interface OnClickListener {
+        void onItemClick(Group item);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterGroupBinding binding;
+
+        ViewHolder(@NonNull AdapterGroupBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 105 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java

@@ -0,0 +1,105 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.Product;
+import com.fongmi.android.tv.bean.Keep;
+import com.fongmi.android.tv.databinding.AdapterVodBinding;
+import com.fongmi.android.tv.utils.ImgUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+
+public class KeepAdapter extends BaseDiffAdapter<Keep, KeepAdapter.ViewHolder> {
+
+    private final OnClickListener listener;
+    private int width, height;
+    private boolean delete;
+
+    public KeepAdapter(OnClickListener listener) {
+        this.listener = listener;
+        setLayoutSize();
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Keep item);
+
+        void onItemDelete(Keep item);
+
+        boolean onLongClick();
+    }
+
+    private void setLayoutSize() {
+        int space = ResUtil.dp2px(48) + ResUtil.dp2px(16 * (Product.getColumn() - 1));
+        int base = ResUtil.getScreenWidth() - space;
+        width = base / Product.getColumn();
+        height = (int) (width / 0.75f);
+    }
+
+    public boolean isDelete() {
+        return delete;
+    }
+
+    public void setDelete(boolean delete) {
+        this.delete = delete;
+        notifyItemRangeChanged(0, getItemCount());
+    }
+
+    private void setClickListener(View root, Keep item) {
+        root.setOnLongClickListener(view -> listener.onLongClick());
+        root.setOnClickListener(view -> {
+            if (isDelete()) listener.onItemDelete(item);
+            else listener.onItemClick(item);
+        });
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        ViewHolder holder = new ViewHolder(AdapterVodBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+        holder.binding.getRoot().getLayoutParams().width = width;
+        holder.binding.image.getLayoutParams().height = height;
+        return holder;
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Keep item = getItem(position);
+        setClickListener(holder.itemView, item);
+        holder.binding.name.setText(item.getVodName());
+        holder.binding.remark.setVisibility(View.GONE);
+        holder.binding.site.setVisibility(View.VISIBLE);
+        holder.binding.site.setText(item.getSiteName());
+        holder.binding.delete.setVisibility(!delete ? View.GONE : View.VISIBLE);
+        ImgUtil.load(item.getVodName(), item.getVodPic(), holder.binding.image);
+    }
+
+    public class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterVodBinding binding;
+
+        public ViewHolder(@NonNull AdapterVodBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+            setFocusListener();
+        }
+
+        private void setFocusListener() {
+            itemView.setOnFocusChangeListener((v, hasFocus) -> {
+                if (hasFocus) {
+                    v.animate().scaleX(1.1f).scaleY(1.1f).setDuration(150).start();
+                    v.setTranslationZ(10f);
+                    v.setSelected(true);
+                } else {
+                    v.animate().scaleX(1f).scaleY(1f).setDuration(150).start();
+                    v.setTranslationZ(0f);
+                    v.setSelected(false);
+                }
+            });
+        }
+    }
+}

+ 120 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeyboardAdapter.java

@@ -0,0 +1,120 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.databinding.AdapterKeyboardIconBinding;
+import com.fongmi.android.tv.databinding.AdapterKeyboardTextBinding;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+
+public class KeyboardAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
+
+    private final List<Integer> icons = Arrays.asList(R.drawable.ic_keyboard_remote, R.drawable.ic_keyboard_left, R.drawable.ic_keyboard_right, R.drawable.ic_keyboard_back, R.drawable.ic_keyboard_search, R.drawable.ic_keyboard, R.drawable.ic_setting_home);
+    private final List<String> enList = Arrays.asList("A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9");
+    private final List<String> twList = Arrays.asList("ㄅ", "ㄆ", "ㄇ", "ㄈ", "ㄉ", "ㄊ", "ㄋ", "ㄌ", "ㄍ", "ㄎ", "ㄏ", "ㄐ", "ㄑ", "ㄒ", "ㄓ", "ㄔ", "ㄕ", "ㄖ", "ㄗ", "ㄘ", "ㄙ", "ㄧ", "ㄨ", "ㄩ", "ㄚ", "ㄛ", "ㄜ", "ㄝ", "ㄞ", "ㄟ", "ㄠ", "ㄡ", "ㄢ", "ㄣ", "ㄤ", "ㄥ", "ㄦ", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9");
+    private final OnClickListener listener;
+    private final List<Object> mItems;
+
+    public KeyboardAdapter(OnClickListener listener) {
+        this.mItems = new ArrayList<>();
+        this.listener = listener;
+        this.mItems.addAll(icons);
+        this.mItems.addAll(Setting.isZhuyin() ? twList : enList);
+    }
+
+    public interface OnClickListener {
+
+        void onTextClick(String text);
+
+        void onIconClick(int resId);
+
+        boolean onLongClick(int resId);
+    }
+
+    public void toggle() {
+        Setting.putZhuyin(!Setting.isZhuyin());
+        mItems.removeAll(Setting.isZhuyin() ? enList : twList);
+        mItems.addAll(icons.size(), Setting.isZhuyin() ? twList : enList);
+        notifyItemRangeRemoved(icons.size(), Setting.isZhuyin() ? enList.size() : twList.size());
+        notifyItemRangeInserted(icons.size(), Setting.isZhuyin() ? twList.size() : enList.size());
+    }
+
+    @Override
+    public int getItemViewType(int position) {
+        return mItems.get(position) instanceof String ? 0 : 1;
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        if (viewType == 0) return new TextHolder(AdapterKeyboardTextBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+        else return new IconHolder(AdapterKeyboardIconBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
+        switch (getItemViewType(position)) {
+            case 0:
+                TextHolder text = (TextHolder) holder;
+                text.binding.text.setText(mItems.get(position).toString());
+                break;
+            case 1:
+                IconHolder icon = (IconHolder) holder;
+                icon.binding.icon.setImageResource((int) mItems.get(position));
+                break;
+        }
+    }
+
+    class TextHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
+
+        private final AdapterKeyboardTextBinding binding;
+
+        TextHolder(@NonNull AdapterKeyboardTextBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+            itemView.setOnClickListener(this);
+        }
+
+        @Override
+        public void onClick(View view) {
+            listener.onTextClick(mItems.get(getLayoutPosition()).toString());
+        }
+    }
+
+    class IconHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener {
+
+        private final AdapterKeyboardIconBinding binding;
+
+        IconHolder(@NonNull AdapterKeyboardIconBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+            itemView.setOnClickListener(this);
+            itemView.setOnLongClickListener(this);
+        }
+
+        @Override
+        public void onClick(View view) {
+            listener.onIconClick((int) mItems.get(getLayoutPosition()));
+        }
+
+        @Override
+        public boolean onLongClick(View view) {
+            return listener.onLongClick((int) mItems.get(getLayoutPosition()));
+        }
+    }
+}

+ 81 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java

@@ -0,0 +1,81 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.api.config.LiveConfig;
+import com.fongmi.android.tv.bean.Live;
+import com.fongmi.android.tv.databinding.AdapterLiveBinding;
+
+import java.util.List;
+
+public class LiveAdapter extends RecyclerView.Adapter<LiveAdapter.ViewHolder> {
+
+    private final OnClickListener listener;
+    private final List<Live> mItems;
+    private boolean action;
+
+    public LiveAdapter(OnClickListener listener) {
+        this.listener = listener;
+        this.mItems = LiveConfig.get().getLives();
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Live item);
+
+        void onBootClick(int position, Live item);
+
+        void onPassClick(int position, Live item);
+
+        boolean onBootLongClick(Live item);
+
+        boolean onPassLongClick(Live item);
+    }
+
+    public void setAction(boolean action) {
+        this.action = action;
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterLiveBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Live item = mItems.get(position);
+        holder.binding.text.setText(item.getName());
+        holder.binding.text.setSelected(item.isActivated());
+        holder.binding.text.setActivated(item.isActivated());
+        holder.binding.boot.setImageResource(item.getBootIcon());
+        holder.binding.pass.setImageResource(item.getPassIcon());
+        holder.binding.boot.setVisibility(action ? View.VISIBLE : View.GONE);
+        holder.binding.pass.setVisibility(action ? View.VISIBLE : View.GONE);
+        holder.binding.text.setOnClickListener(v -> listener.onItemClick(item));
+        holder.binding.boot.setOnClickListener(v -> listener.onBootClick(position, item));
+        holder.binding.pass.setOnClickListener(v -> listener.onPassClick(position, item));
+        holder.binding.boot.setOnLongClickListener(v -> listener.onBootLongClick(item));
+        holder.binding.pass.setOnLongClickListener(v -> listener.onPassLongClick(item));
+    }
+
+    public class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterLiveBinding binding;
+
+        public ViewHolder(@NonNull AdapterLiveBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 82 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ParseAdapter.java

@@ -0,0 +1,82 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.Parse;
+import com.fongmi.android.tv.databinding.AdapterParseBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ParseAdapter extends RecyclerView.Adapter<ParseAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<Parse> mItems;
+
+    public ParseAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+    }
+
+    public void addAll(List<Parse> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    public Parse get(int position) {
+        return mItems.get(position);
+    }
+
+    public Parse first() {
+        return mItems.get(0);
+    }
+
+    public int getPosition() {
+        for (int i = 0; i < mItems.size(); i++) if (mItems.get(i).isActivated()) return i;
+        return 0;
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterParseBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Parse item = mItems.get(position);
+        holder.binding.text.setText(item.getName());
+        holder.binding.text.setActivated(item.isActivated());
+        holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item));
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Parse item);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterParseBinding binding;
+
+        ViewHolder(@NonNull AdapterParseBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 78 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/PartAdapter.java

@@ -0,0 +1,78 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.databinding.AdapterPartBinding;
+import com.fongmi.android.tv.utils.ResUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class PartAdapter extends RecyclerView.Adapter<PartAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<String> mItems;
+    private final int maxWidth;
+    private int nextFocusUp;
+
+    public PartAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+        maxWidth = ResUtil.getScreenWidth() - ResUtil.dp2px(48);
+    }
+
+    public void addAll(List<String> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    public void setNextFocusUp(int nextFocusUp) {
+        this.nextFocusUp = nextFocusUp;
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterPartBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        String text = mItems.get(position);
+        holder.binding.text.setText(text);
+        holder.binding.text.setMaxWidth(maxWidth);
+        holder.binding.text.setNextFocusUpId(nextFocusUp);
+        holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(text));
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(String item);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterPartBinding binding;
+
+        ViewHolder(@NonNull AdapterPartBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 71 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QualityAdapter.java

@@ -0,0 +1,71 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.Result;
+import com.fongmi.android.tv.databinding.AdapterQualityBinding;
+
+public class QualityAdapter extends RecyclerView.Adapter<QualityAdapter.ViewHolder> {
+
+    private final OnClickListener listener;
+    private Result result;
+    private int position;
+
+    public QualityAdapter(OnClickListener listener) {
+        this.listener = listener;
+        this.result = Result.empty();
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Result result);
+    }
+
+    public int getPosition() {
+        return position;
+    }
+
+    public void addAll(Result result) {
+        this.result = result;
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public int getItemCount() {
+        return result.getUrl().getValues().size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterQualityBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        holder.binding.text.setText(result.getUrl().n(position));
+        holder.binding.text.setOnClickListener(v -> onItemClick(position));
+        holder.binding.text.setActivated(result.getUrl().getPosition() == position);
+    }
+
+    private void onItemClick(int position) {
+        this.position = position;
+        result.getUrl().set(position);
+        listener.onItemClick(result);
+        notifyItemRangeChanged(0, getItemCount());
+    }
+
+    public class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterQualityBinding binding;
+
+        ViewHolder(@NonNull AdapterQualityBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 85 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java

@@ -0,0 +1,85 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.Vod;
+import com.fongmi.android.tv.databinding.AdapterQuickBinding;
+import com.fongmi.android.tv.utils.ResUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class QuickAdapter extends RecyclerView.Adapter<QuickAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<Vod> mItems;
+    private final int width;
+
+    public QuickAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+        int space = ResUtil.dp2px(24) + ResUtil.dp2px(32);
+        width = (ResUtil.getScreenWidth() - space) / 4;
+    }
+
+    public void addAll(List<Vod> items) {
+        int start = mItems.size();
+        mItems.addAll(items);
+        notifyItemRangeInserted(start, items.size());
+    }
+
+    public void remove(int position) {
+        mItems.remove(position);
+        notifyItemRemoved(position);
+    }
+
+    public void clear() {
+        mItems.clear();
+        notifyDataSetChanged();
+    }
+
+    public Vod get(int position) {
+        return mItems.get(position);
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        ViewHolder holder = new ViewHolder(AdapterQuickBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+        holder.binding.getRoot().getLayoutParams().width = width;
+        return holder;
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Vod item = mItems.get(position);
+        holder.binding.name.setText(item.getName());
+        holder.binding.site.setText(item.getSiteName());
+        holder.binding.remark.setText(item.getRemarks());
+        holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item));
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Vod item);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterQuickBinding binding;
+
+        ViewHolder(@NonNull AdapterQuickBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 90 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/RecordAdapter.java

@@ -0,0 +1,90 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.databinding.AdapterSearchRecordBinding;
+import com.google.gson.reflect.TypeToken;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class RecordAdapter extends RecyclerView.Adapter<RecordAdapter.ViewHolder> {
+
+    private final OnClickListener listener;
+    private final List<String> mItems;
+
+    public RecordAdapter(OnClickListener listener) {
+        this.listener = listener;
+        this.mItems = getItems();
+        this.listener.onDataChanged(mItems.size());
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(String text);
+
+        void onDataChanged(int size);
+    }
+
+    private List<String> getItems() {
+        if (Setting.getKeyword().isEmpty()) return new ArrayList<>();
+        return App.gson().fromJson(Setting.getKeyword(), new TypeToken<List<String>>() {}.getType());
+    }
+
+    private void checkToAdd(String item) {
+        mItems.remove(item);
+        mItems.add(0, item);
+        if (mItems.size() > 9) mItems.remove(9);
+    }
+
+    public void add(String item) {
+        checkToAdd(item);
+        notifyDataSetChanged();
+        listener.onDataChanged(getItemCount());
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterSearchRecordBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        String text = mItems.get(position);
+        holder.binding.text.setText(text);
+        holder.binding.text.setOnClickListener(v -> listener.onItemClick(text));
+    }
+
+    public class ViewHolder extends RecyclerView.ViewHolder implements View.OnLongClickListener {
+
+        private final AdapterSearchRecordBinding binding;
+
+        public ViewHolder(@NonNull AdapterSearchRecordBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+            itemView.setOnLongClickListener(this);
+        }
+
+        @Override
+        public boolean onLongClick(View v) {
+            mItems.remove(getLayoutPosition());
+            notifyItemRemoved(getLayoutPosition());
+            listener.onDataChanged(getItemCount());
+            Setting.putKeyword(App.gson().toJson(mItems));
+            return true;
+        }
+    }
+}

+ 79 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/RestoreAdapter.java

@@ -0,0 +1,79 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.databinding.AdapterRestoreBinding;
+import com.github.catvod.utils.Path;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+public class RestoreAdapter extends RecyclerView.Adapter<RestoreAdapter.ViewHolder> {
+
+    private final OnClickListener listener;
+    private final List<File> mItems;
+
+    public RestoreAdapter(OnClickListener listener) {
+        this.mItems = new ArrayList<>();
+        this.listener = listener;
+        this.addAll();
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(File item);
+
+        void onDeleteClick(File item);
+    }
+
+    public void addAll() {
+        File[] files = Path.tv().listFiles();
+        if (files == null) files = new File[0];
+        for (File file : files) if (file.getName().startsWith("tv") && file.getName().endsWith(".bk.gz")) mItems.add(file);
+        if (!mItems.isEmpty()) mItems.sort((f1, f2) -> Long.compare(f2.lastModified(), f1.lastModified()));
+        notifyDataSetChanged();
+    }
+
+    public int remove(File item) {
+        int position = mItems.indexOf(item);
+        if (position == -1) return -1;
+        Path.clear(item);
+        mItems.remove(position);
+        notifyItemRemoved(position);
+        return getItemCount();
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterRestoreBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        File item = mItems.get(position);
+        holder.binding.text.setText(item.getName());
+        holder.binding.text.setOnClickListener(v -> listener.onItemClick(item));
+        holder.binding.delete.setOnClickListener(v -> listener.onDeleteClick(item));
+    }
+
+    public class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterRestoreBinding binding;
+
+        public ViewHolder(@NonNull AdapterRestoreBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 115 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java

@@ -0,0 +1,115 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.bean.Site;
+import com.fongmi.android.tv.databinding.AdapterSiteBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SiteAdapter extends RecyclerView.Adapter<SiteAdapter.ViewHolder> {
+
+    private final OnClickListener listener;
+    private final List<Site> mItems;
+    private int type;
+
+    public SiteAdapter(OnClickListener listener) {
+        this.listener = listener;
+        this.mItems = new ArrayList<>();
+        this.addAll();
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Site item);
+    }
+
+    public void setType(int type) {
+        this.type = type;
+        notifyDataSetChanged();
+    }
+
+    public void selectAll() {
+        setEnable(type != 3);
+    }
+
+    public void cancelAll() {
+        setEnable(type == 3);
+    }
+
+    private void addAll() {
+        for (Site site : VodConfig.get().getSites()) if (!site.isHide()) mItems.add(site);
+    }
+
+    public List<Site> getItems() {
+        return mItems;
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterSiteBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Site item = mItems.get(position);
+        holder.binding.text.setText(item.getName());
+        holder.binding.check.setChecked(getChecked(item));
+        holder.binding.text.setSelected(item.isActivated());
+        holder.binding.text.setActivated(item.isActivated());
+        holder.binding.check.setVisibility(type == 0 ? View.GONE : View.VISIBLE);
+        holder.binding.getRoot().setOnLongClickListener(v -> setLongListener(item));
+        holder.binding.getRoot().setOnClickListener(v -> setListener(item, position));
+        holder.binding.text.setGravity(Setting.getSiteMode() == 0 ? Gravity.CENTER : Gravity.START);
+    }
+
+    private boolean getChecked(Site item) {
+        if (type == 1) return item.isSearchable();
+        if (type == 2) return item.isChangeable();
+        return false;
+    }
+
+    private void setListener(Site item, int position) {
+        if (type == 0) listener.onItemClick(item);
+        if (type == 1) item.setSearchable(!item.isSearchable()).save();
+        if (type == 2) item.setChangeable(!item.isChangeable()).save();
+        if (type != 0) notifyItemChanged(position);
+    }
+
+    private boolean setLongListener(Site item) {
+        if (type == 1) setEnable(!item.isSearchable());
+        if (type == 2) setEnable(!item.isChangeable());
+        return true;
+    }
+
+    private void setEnable(boolean enable) {
+        if (type == 1) for (Site site : mItems) site.setSearchable(enable).save();
+        if (type == 2) for (Site site : mItems) site.setChangeable(enable).save();
+        notifyItemRangeChanged(0, getItemCount());
+    }
+
+    public class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterSiteBinding binding;
+
+        ViewHolder(@NonNull AdapterSiteBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 83 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java

@@ -0,0 +1,83 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.bean.Cache;
+import com.fongmi.android.tv.bean.Class;
+import com.fongmi.android.tv.databinding.AdapterTypeBinding;
+import com.fongmi.android.tv.utils.ResUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TypeAdapter extends RecyclerView.Adapter<TypeAdapter.ViewHolder> {
+
+    private final OnClickListener mListener;
+    private final List<Class> mItems;
+
+    public TypeAdapter(OnClickListener listener) {
+        mListener = listener;
+        mItems = new ArrayList<>();
+    }
+
+    public void addAll(List<Class> items) {
+        mItems.clear();
+        mItems.addAll(items);
+        notifyDataSetChanged();
+    }
+
+    public Class get(int position) {
+        return mItems.get(position);
+    }
+
+    public int indexOf(Class item) {
+        return mItems.indexOf(item);
+    }
+
+    @Override
+    public int getItemCount() {
+        return mItems.size();
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterTypeBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Class item = mItems.get(position);
+        holder.binding.text.setText(item.getTypeName());
+        holder.binding.text.setCompoundDrawablePadding(ResUtil.dp2px(4));
+        holder.binding.text.setCompoundDrawablesWithIntrinsicBounds(0, 0, getIcon(item), 0);
+        holder.binding.text.setListener(() -> mListener.onRefresh(item));
+        holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item));
+    }
+
+    private int getIcon(Class item) {
+        return Cache.get(item).isEmpty() ? 0 : item.getFilter() ? R.drawable.ic_vod_filter_off : R.drawable.ic_vod_filter_on;
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Class item);
+
+        void onRefresh(Class item);
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterTypeBinding binding;
+
+        ViewHolder(@NonNull AdapterTypeBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 47 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/adapter/WordAdapter.java

@@ -0,0 +1,47 @@
+package com.fongmi.android.tv.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.Word;
+import com.fongmi.android.tv.databinding.AdapterSearchWordBinding;
+
+public class WordAdapter extends BaseDiffAdapter<Word.Data, WordAdapter.ViewHolder> {
+
+    private final OnClickListener listener;
+
+    public WordAdapter(OnClickListener listener) {
+        this.listener = listener;
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(String text);
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        return new ViewHolder(AdapterSearchWordBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Word.Data item = getItem(position);
+        holder.binding.text.setText(item.getTitle());
+        holder.binding.text.setOnClickListener(v -> listener.onItemClick(item.getTitle()));
+    }
+
+    public class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final AdapterSearchWordBinding binding;
+
+        public ViewHolder(@NonNull AdapterSearchWordBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 123 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java

@@ -0,0 +1,123 @@
+package com.fongmi.android.tv.ui.base;
+
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.activity.OnBackPressedCallback;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.FragmentActivity;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.ui.custom.CustomWallView;
+import com.fongmi.android.tv.utils.Util;
+
+import org.greenrobot.eventbus.EventBus;
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+import me.jessyan.autosize.AutoSizeCompat;
+
+public abstract class BaseActivity extends AppCompatActivity {
+
+    protected abstract ViewBinding getBinding();
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(getBinding().getRoot());
+        EventBus.getDefault().register(this);
+        initView(savedInstanceState);
+        Util.hideSystemUI(this);
+        setBackCallback();
+        initEvent();
+    }
+
+    @Override
+    public void setContentView(View view) {
+        super.setContentView(view);
+        if (!customWall()) return;
+        ((ViewGroup) findViewById(android.R.id.content)).addView(new CustomWallView(this, null), 0, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
+    }
+
+    protected FragmentActivity getActivity() {
+        return this;
+    }
+
+    protected boolean customWall() {
+        return true;
+    }
+
+    protected void initView(Bundle savedInstanceState) {
+    }
+
+    protected void initEvent() {
+    }
+
+    protected boolean isVisible(View view) {
+        return view.getVisibility() == View.VISIBLE;
+    }
+
+    protected boolean isGone(View view) {
+        return view.getVisibility() == View.GONE;
+    }
+
+    protected void notifyItemChanged(RecyclerView view, RecyclerView.Adapter<?> adapter) {
+        view.post(() -> adapter.notifyDataSetChanged());
+    }
+
+    private void setBackCallback() {
+        getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
+            @Override
+            public void handleOnBackPressed() {
+                onBackInvoked();
+            }
+        });
+    }
+
+    private Resources hackResources(Resources resources) {
+        try {
+            AutoSizeCompat.autoConvertDensityOfGlobal(resources);
+            return resources;
+        } catch (Exception ignored) {
+            return resources;
+        }
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onSubscribe(Object o) {
+    }
+
+    @Override
+    public Resources getResources() {
+        return hackResources(super.getResources());
+    }
+
+    @Override
+    public void onConfigurationChanged(@NonNull Configuration newConfig) {
+        super.onConfigurationChanged(newConfig);
+        Util.hideSystemUI(this);
+    }
+
+    @Override
+    public void onWindowFocusChanged(boolean hasFocus) {
+        super.onWindowFocusChanged(hasFocus);
+        if (hasFocus) Util.hideSystemUI(this);
+    }
+
+    protected void onBackInvoked() {
+        finish();
+    }
+
+    @Override
+    protected void onDestroy() {
+        EventBus.getDefault().unregister(this);
+        super.onDestroy();
+    }
+}

+ 61 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseFragment.java

@@ -0,0 +1,61 @@
+package com.fongmi.android.tv.ui.base;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.viewbinding.ViewBinding;
+
+public abstract class BaseFragment extends Fragment {
+
+    private boolean isViewCreated;
+    private boolean isDataLoaded;
+
+    protected abstract ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container);
+
+    @Nullable
+    @Override
+    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+        return getBinding(inflater, container).getRoot();
+    }
+
+    @Override
+    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+        isViewCreated = true;
+        if (getUserVisibleHint()) {
+            lazyLoad();
+            isDataLoaded = true;
+        }
+    }
+
+    private void lazyLoad() {
+        initView();
+        initEvent();
+    }
+
+    protected void initView() {
+    }
+
+    protected void initEvent() {
+    }
+
+    @Override
+    public void setUserVisibleHint(boolean isVisibleToUser) {
+        super.setUserVisibleHint(isVisibleToUser);
+        if (isVisibleToUser && isViewCreated && !isDataLoaded) {
+            lazyLoad();
+            isDataLoaded = true;
+        }
+    }
+
+    @Override
+    public void onDestroyView() {
+        super.onDestroyView();
+        isViewCreated = false;
+        isDataLoaded = false;
+    }
+}

+ 18 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseVodHolder.java

@@ -0,0 +1,18 @@
+package com.fongmi.android.tv.ui.base;
+
+import android.view.View;
+
+import androidx.leanback.widget.Presenter;
+
+import com.fongmi.android.tv.bean.Vod;
+
+public abstract class BaseVodHolder extends Presenter.ViewHolder {
+
+    public BaseVodHolder(View view) {
+        super(view);
+    }
+
+    public abstract void initView(Vod item);
+
+    public abstract void unbind();
+}

+ 8 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/base/ViewType.java

@@ -0,0 +1,8 @@
+package com.fongmi.android.tv.ui.base;
+
+public class ViewType {
+
+    public static final int RECT = 0;
+    public static final int OVAL = 1;
+    public static final int LIST = 2;
+}

+ 38 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomEditText.java

@@ -0,0 +1,38 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.AppCompatEditText;
+
+import com.fongmi.android.tv.utils.KeyUtil;
+
+public class CustomEditText extends AppCompatEditText {
+
+    public CustomEditText(@NonNull Context context) {
+        super(context);
+    }
+
+    public CustomEditText(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    private View focusSearch(KeyEvent event) {
+        if (KeyUtil.isUpKey(event)) return getParent().focusSearch(this, FOCUS_UP);
+        if (KeyUtil.isDownKey(event)) return getParent().focusSearch(this, FOCUS_DOWN);
+        if (KeyUtil.isLeftKey(event) && getSelectionStart() == 0) return getParent().focusSearch(this, FOCUS_LEFT);
+        if (KeyUtil.isRightKey(event) && getSelectionStart() == getText().length()) return getParent().focusSearch(this, FOCUS_RIGHT);
+        return null;
+    }
+
+    @Override
+    public boolean onKeyDown(int keyCode, KeyEvent event) {
+        View v = focusSearch(event);
+        if (v != null) return v.requestFocus();
+        return super.onKeyDown(keyCode, event);
+    }
+}

+ 134 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownLive.java

@@ -0,0 +1,134 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.view.GestureDetector;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import androidx.annotation.NonNull;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.Constant;
+import com.fongmi.android.tv.utils.KeyUtil;
+
+public class CustomKeyDownLive extends GestureDetector.SimpleOnGestureListener {
+
+    private final GestureDetector detector;
+    private final StringBuilder text;
+    private final Listener listener;
+    private long holdTime;
+
+    private final Runnable runnable = new Runnable() {
+        @Override
+        public void run() {
+            listener.onFind(text.toString());
+            text.setLength(0);
+        }
+    };
+
+    public static CustomKeyDownLive create(Context context) {
+        return new CustomKeyDownLive(context);
+    }
+
+    private CustomKeyDownLive(Context context) {
+        this.text = new StringBuilder();
+        this.listener = (Listener) context;
+        this.detector = new GestureDetector(context, this);
+    }
+
+    public boolean onTouchEvent(MotionEvent e) {
+        return detector.onTouchEvent(e);
+    }
+
+    public boolean hasEvent(KeyEvent event) {
+        return KeyUtil.isEnterKey(event) || KeyUtil.isUpKey(event) || KeyUtil.isDownKey(event) || KeyUtil.isLeftKey(event) || KeyUtil.isRightKey(event) || KeyUtil.isDigitKey(event) || KeyUtil.isMenuKey(event) || event.isLongPress();
+    }
+
+    public void onKeyDown(KeyEvent event) {
+        if (listener.dispatch(true)) check(event);
+    }
+
+    private void check(KeyEvent event) {
+        if (KeyUtil.isActionDown(event) && KeyUtil.isLeftKey(event)) {
+            listener.onSeeking(subTime());
+        } else if (KeyUtil.isActionDown(event) && KeyUtil.isRightKey(event)) {
+            listener.onSeeking(addTime());
+        } else if (KeyUtil.isActionDown(event) && KeyUtil.isUpKey(event)) {
+            listener.onKeyUp();
+        } else if (KeyUtil.isActionDown(event) && KeyUtil.isDownKey(event)) {
+            listener.onKeyDown();
+        } else if (KeyUtil.isActionUp(event) && KeyUtil.isLeftKey(event)) {
+            listener.onKeyLeft(holdTime);
+        } else if (KeyUtil.isActionUp(event) && KeyUtil.isRightKey(event)) {
+            listener.onKeyRight(holdTime);
+        } else if (KeyUtil.isActionUp(event) && KeyUtil.isDigitKey(event)) {
+            onKeyDown(event.getKeyCode());
+        } else if (KeyUtil.isActionUp(event) && KeyUtil.isEnterKey(event)) {
+            listener.onKeyCenter();
+        } else if (KeyUtil.isMenuKey(event) || event.isLongPress() && KeyUtil.isEnterKey(event)) {
+            listener.onMenu();
+        }
+    }
+
+    private void onKeyDown(int keyCode) {
+        if (text.length() >= 4) return;
+        text.append(getNumber(keyCode));
+        listener.onShow(text.toString());
+        App.post(runnable, 2000);
+    }
+
+    @Override
+    public boolean onDoubleTap(@NonNull MotionEvent e) {
+        if (listener.dispatch(false)) listener.onDoubleTap();
+        return true;
+    }
+
+    @Override
+    public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
+        if (listener.dispatch(false)) listener.onSingleTap();
+        return true;
+    }
+
+    private int getNumber(int keyCode) {
+        return keyCode >= 144 ? keyCode - 144 : keyCode - 7;
+    }
+
+    private long addTime() {
+        return holdTime = holdTime + Constant.INTERVAL_SEEK;
+    }
+
+    private long subTime() {
+        return holdTime = holdTime - Constant.INTERVAL_SEEK;
+    }
+
+    public void reset() {
+        holdTime = 0;
+    }
+
+    public interface Listener {
+
+        boolean dispatch(boolean check);
+
+        void onShow(String number);
+
+        void onFind(String number);
+
+        void onSeeking(long time);
+
+        void onKeyUp();
+
+        void onKeyDown();
+
+        void onKeyLeft(long time);
+
+        void onKeyRight(long time);
+
+        void onKeyCenter();
+
+        void onMenu();
+
+        void onSingleTap();
+
+        void onDoubleTap();
+    }
+}

+ 114 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java

@@ -0,0 +1,114 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.app.Activity;
+import android.view.GestureDetector;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import androidx.annotation.NonNull;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.Constant;
+import com.fongmi.android.tv.utils.KeyUtil;
+
+public class CustomKeyDownVod extends GestureDetector.SimpleOnGestureListener {
+
+    private final GestureDetector detector;
+    private final Listener listener;
+    private boolean changeSpeed;
+    private boolean full;
+    private long holdTime;
+
+    public static CustomKeyDownVod create(Activity activity) {
+        return new CustomKeyDownVod(activity);
+    }
+
+    private CustomKeyDownVod(Activity activity) {
+        this.detector = new GestureDetector(activity, this);
+        this.listener = (Listener) activity;
+    }
+
+    public boolean onTouchEvent(MotionEvent e) {
+        if (!full) return false;
+        return detector.onTouchEvent(e);
+    }
+
+    public void setFull(boolean full) {
+        this.full = full;
+    }
+
+    public boolean hasEvent(KeyEvent event) {
+        return KeyUtil.isEnterKey(event) || KeyUtil.isUpKey(event) || KeyUtil.isDownKey(event) || KeyUtil.isLeftKey(event) || KeyUtil.isRightKey(event);
+    }
+
+    public boolean onKeyDown(KeyEvent event) {
+        check(event);
+        return true;
+    }
+
+    private void check(KeyEvent event) {
+        if (KeyUtil.isActionDown(event) && KeyUtil.isLeftKey(event)) {
+            listener.onSeeking(subTime());
+        } else if (KeyUtil.isActionDown(event) && KeyUtil.isRightKey(event)) {
+            listener.onSeeking(addTime());
+        } else if (KeyUtil.isActionUp(event) && (KeyUtil.isLeftKey(event) || KeyUtil.isRightKey(event))) {
+            App.post(() -> listener.onSeekEnd(holdTime), 250);
+        } else if (KeyUtil.isActionUp(event) && KeyUtil.isUpKey(event)) {
+            if (changeSpeed) listener.onSpeedEnd();
+            else listener.onKeyUp();
+            changeSpeed = false;
+        } else if (KeyUtil.isActionUp(event) && KeyUtil.isDownKey(event)) {
+            listener.onKeyDown();
+        } else if (KeyUtil.isActionUp(event) && KeyUtil.isEnterKey(event)) {
+            listener.onKeyCenter();
+        } else if (event.isLongPress() && KeyUtil.isUpKey(event)) {
+            listener.onSpeedUp();
+            changeSpeed = true;
+        }
+    }
+
+    @Override
+    public boolean onDoubleTap(@NonNull MotionEvent e) {
+        listener.onDoubleTap();
+        return true;
+    }
+
+    @Override
+    public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
+        listener.onSingleTap();
+        return true;
+    }
+
+    private long addTime() {
+        return holdTime = holdTime + Constant.INTERVAL_SEEK;
+    }
+
+    private long subTime() {
+        return holdTime = holdTime - Constant.INTERVAL_SEEK;
+    }
+
+    public void reset() {
+        holdTime = 0;
+    }
+
+    public interface Listener {
+
+        void onSeeking(long time);
+
+        void onSeekEnd(long time);
+
+        void onSpeedUp();
+
+        void onSpeedEnd();
+
+        void onKeyUp();
+
+        void onKeyDown();
+
+        void onKeyCenter();
+
+        void onSingleTap();
+
+        void onDoubleTap();
+    }
+}

+ 82 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyboard.java

@@ -0,0 +1,82 @@
+package com.fongmi.android.tv.ui.custom;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.databinding.ActivitySearchBinding;
+import com.fongmi.android.tv.ui.adapter.KeyboardAdapter;
+
+public class CustomKeyboard implements KeyboardAdapter.OnClickListener {
+
+    private final ActivitySearchBinding binding;
+    private final Callback callback;
+    private KeyboardAdapter adapter;
+
+    public static void init(Callback callback, ActivitySearchBinding binding) {
+        new CustomKeyboard(callback, binding).initView();
+    }
+
+    public CustomKeyboard(Callback callback, ActivitySearchBinding binding) {
+        this.callback = callback;
+        this.binding = binding;
+    }
+
+    private void initView() {
+        binding.keyboard.setItemAnimator(null);
+        binding.keyboard.setHasFixedSize(false);
+        binding.keyboard.addItemDecoration(new SpaceItemDecoration(7, 8));
+        binding.keyboard.setAdapter(adapter = new KeyboardAdapter(this));
+    }
+
+    @Override
+    public void onTextClick(String text) {
+        StringBuilder sb = new StringBuilder(binding.keyword.getText().toString());
+        int cursor = binding.keyword.getSelectionStart();
+        if (binding.keyword.length() > 19) return;
+        sb.insert(cursor, text);
+        binding.keyword.setText(sb.toString());
+        binding.keyword.setSelection(cursor + 1);
+    }
+
+    @Override
+    public void onIconClick(int resId) {
+        StringBuilder sb = new StringBuilder(binding.keyword.getText().toString());
+        int cursor = binding.keyword.getSelectionStart();
+        if (resId == R.drawable.ic_setting_home) callback.showDialog();
+        else if (resId == R.drawable.ic_keyboard_remote) callback.onRemote();
+        else if (resId == R.drawable.ic_keyboard_search) callback.onSearch();
+        else if (resId == R.drawable.ic_keyboard_left) onMoveLeft(cursor);
+        else if (resId == R.drawable.ic_keyboard_right) onMoveRight(cursor);
+        else if (resId == R.drawable.ic_keyboard_back) onBackspace(sb, cursor);
+        else if (resId == R.drawable.ic_keyboard) adapter.toggle();
+    }
+
+    private void onMoveLeft(int cursor) {
+        binding.keyword.setSelection(--cursor < 0 ? 0 : cursor);
+    }
+
+    private void onMoveRight(int cursor) {
+        binding.keyword.setSelection(++cursor > binding.keyword.length() ? binding.keyword.length() : cursor);
+    }
+
+    private void onBackspace(StringBuilder sb, int cursor) {
+        if (cursor <= 0) return;
+        sb.deleteCharAt(cursor - 1);
+        binding.keyword.setText(sb.toString());
+        binding.keyword.setSelection(cursor - 1);
+    }
+
+    @Override
+    public boolean onLongClick(int resId) {
+        if (resId != R.drawable.ic_keyboard_back) return false;
+        binding.keyword.setText("");
+        return true;
+    }
+
+    public interface Callback {
+
+        void showDialog();
+
+        void onRemote();
+
+        void onSearch();
+    }
+}

+ 59 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomLeftRightLayout.java

@@ -0,0 +1,59 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.widget.LinearLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.fongmi.android.tv.utils.KeyUtil;
+
+public class CustomLeftRightLayout extends LinearLayout {
+
+    private LeftListener leftListener;
+    private RightListener rightListener;
+
+    public CustomLeftRightLayout(@NonNull Context context) {
+        super(context);
+    }
+
+    public CustomLeftRightLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public void setLeftListener(LeftListener leftListener) {
+        this.leftListener = leftListener;
+    }
+
+    public void setRightListener(RightListener rightListener) {
+        this.rightListener = rightListener;
+    }
+
+    private boolean hasEvent(KeyEvent event) {
+        return KeyUtil.isActionDown(event) && ((leftListener != null && KeyUtil.isLeftKey(event)) || (rightListener != null && KeyUtil.isRightKey(event)));
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (hasEvent(event)) return onKeyDown(event);
+        else return super.dispatchKeyEvent(event);
+    }
+
+    private boolean onKeyDown(KeyEvent event) {
+        if (leftListener != null && KeyUtil.isLeftKey(event)) leftListener.onLeft();
+        if (rightListener != null && KeyUtil.isRightKey(event)) rightListener.onRight();
+        return true;
+    }
+
+    public interface LeftListener {
+
+        void onLeft();
+    }
+
+    public interface RightListener {
+
+        void onRight();
+    }
+}

+ 59 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomLiveListView.java

@@ -0,0 +1,59 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.leanback.widget.VerticalGridView;
+
+import com.fongmi.android.tv.utils.KeyUtil;
+
+public class CustomLiveListView extends VerticalGridView {
+
+    private Callback listener;
+
+    public CustomLiveListView(@NonNull Context context) {
+        super(context);
+    }
+
+    public CustomLiveListView(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public CustomLiveListView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+    }
+
+    public void setListener(Callback listener) {
+        this.listener = listener;
+    }
+
+    private boolean onKeyDown() {
+        if (getSelectedPosition() != getAdapter().getItemCount() - 1) return false;
+        setSelectedPosition(0);
+        return true;
+    }
+
+    private boolean onKeyUp() {
+        if (getSelectedPosition() != 0) return false;
+        setSelectedPosition(getAdapter().getItemCount() - 1);
+        return true;
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(@NonNull KeyEvent event) {
+        if (getVisibility() == View.GONE || event.getAction() != KeyEvent.ACTION_DOWN) return super.dispatchKeyEvent(event);
+        if (getVisibility() == View.VISIBLE && listener != null) listener.setUITimer();
+        if (KeyUtil.isDownKey(event)) return onKeyDown();
+        if (KeyUtil.isUpKey(event)) return onKeyUp();
+        return super.dispatchKeyEvent(event);
+    }
+
+    public interface Callback {
+
+        void setUITimer();
+    }
+}

+ 166 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomMic.java

@@ -0,0 +1,166 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.PorterDuff;
+import android.graphics.Rect;
+import android.speech.RecognizerIntent;
+import android.speech.SpeechRecognizer;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.AppCompatImageView;
+import androidx.fragment.app.FragmentActivity;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.utils.KeyUtil;
+import com.fongmi.android.tv.utils.PermissionUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.github.bassaer.library.MDColor;
+
+import java.util.List;
+
+public class CustomMic extends AppCompatImageView {
+
+    private ActivityResultLauncher<Intent> mLauncher;
+    private CustomTextListener mListener;
+    private SpeechRecognizer mRecognizer;
+    private FragmentActivity mActivity;
+    private boolean mAvailable;
+    private boolean mListen;
+
+    public CustomMic(@NonNull Context context) {
+        super(context);
+    }
+
+    public CustomMic(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    private boolean isAvailable() {
+        return mAvailable;
+    }
+
+    private boolean isListen() {
+        return mListen;
+    }
+
+    private Intent getIntent() {
+        Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
+        intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
+        return intent;
+    }
+
+    public void setListener(FragmentActivity activity, CustomTextListener listener) {
+        mActivity = activity;
+        mListener = listener;
+        mListener.setDone(() -> updateUI(false));
+        mAvailable = SpeechRecognizer.isRecognitionAvailable(activity);
+        initSpeech();
+    }
+
+    private void initSpeech() {
+        if (isAvailable()) initRecognizer();
+        else if (hasResolveActivity()) initLauncher();
+        else setVisibility(GONE);
+    }
+
+    private boolean hasResolveActivity() {
+        return getIntent().resolveActivity(mActivity.getPackageManager()) != null;
+    }
+
+    private void initRecognizer() {
+        if (mRecognizer == null) mRecognizer = SpeechRecognizer.createSpeechRecognizer(mActivity);
+        mRecognizer.setRecognitionListener(mListener);
+    }
+
+    private void initLauncher() {
+        mLauncher = mActivity.registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+            if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null) return;
+            List<String> texts = result.getData().getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS);
+            if (texts != null && !texts.isEmpty()) mListener.onResults(texts.get(0));
+        });
+    }
+
+    public void start() {
+        if (mActivity == null) return;
+        if (isAvailable()) startRecognizer();
+        else launchIntent();
+    }
+
+    private void startRecognizer() {
+        if (mRecognizer == null) return;
+        PermissionUtil.requestAudio(mActivity, allGranted -> {
+            if (allGranted) startListening();
+        });
+    }
+
+    private void startListening() {
+        try {
+            mRecognizer.startListening(getIntent());
+            requestFocus();
+            updateUI(true);
+        } catch (Exception ignored) {
+        }
+    }
+
+    private void launchIntent() {
+        try {
+            if (mLauncher == null) return;
+            mLauncher.launch(getIntent());
+        } catch (Exception ignored) {
+        }
+    }
+
+    public void stop() {
+        if (mRecognizer == null) return;
+        mRecognizer.stopListening();
+        updateUI(false);
+    }
+
+    public void destroy() {
+        if (mRecognizer != null) {
+            mRecognizer.destroy();
+            mRecognizer = null;
+        }
+        if (mLauncher != null) {
+            mLauncher.unregister();
+            mLauncher = null;
+        }
+    }
+
+    private void updateUI(boolean listening) {
+        mListen = listening;
+        if (listening) {
+            startAnimation(ResUtil.getAnim(R.anim.flicker));
+            setColorFilter(MDColor.RED_500, PorterDuff.Mode.SRC_IN);
+        } else {
+            clearAnimation();
+            setColorFilter(MDColor.WHITE, PorterDuff.Mode.SRC_IN);
+        }
+    }
+
+    private boolean onBackKey(KeyEvent event) {
+        if (!isListen() || !KeyUtil.isBackKey(event)) return false;
+        stop();
+        return true;
+    }
+
+    @Override
+    protected void onFocusChanged(boolean gainFocus, int direction, @Nullable Rect previouslyFocusedRect) {
+        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
+        if (gainFocus && isAvailable()) start();
+        else if (!gainFocus) stop();
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (onBackKey(event)) return true;
+        return super.dispatchKeyEvent(event);
+    }
+}

+ 43 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomRowPresenter.java

@@ -0,0 +1,43 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.annotation.SuppressLint;
+
+import androidx.leanback.widget.FocusHighlight;
+import androidx.leanback.widget.HorizontalGridView;
+import androidx.leanback.widget.ListRowPresenter;
+import androidx.leanback.widget.RowPresenter;
+
+import com.fongmi.android.tv.utils.ResUtil;
+
+public class CustomRowPresenter extends ListRowPresenter {
+
+    private final int spacing;
+    private final int strategy;
+
+    public CustomRowPresenter(int spacing) {
+        this(spacing, FocusHighlight.ZOOM_FACTOR_SMALL);
+    }
+
+    @SuppressLint("RestrictedApi")
+    public CustomRowPresenter(int spacing, int focusZoomFactor) {
+        this(spacing, focusZoomFactor, HorizontalGridView.FOCUS_SCROLL_ITEM);
+    }
+
+    public CustomRowPresenter(int spacing, int focusZoomFactor, int strategy) {
+        super(focusZoomFactor);
+        this.spacing = spacing;
+        this.strategy = strategy;
+        setShadowEnabled(false);
+        setSelectEffectEnabled(false);
+        setKeepChildForeground(false);
+    }
+
+    @Override
+    @SuppressLint("RestrictedApi")
+    protected void initializeRowViewHolder(RowPresenter.ViewHolder holder) {
+        super.initializeRowViewHolder(holder);
+        ViewHolder vh = (ViewHolder) holder;
+        vh.getGridView().setFocusScrollStrategy(strategy);
+        vh.getGridView().setHorizontalSpacing(ResUtil.dp2px(spacing));
+    }
+}

+ 72 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomScroller.java

@@ -0,0 +1,72 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.bean.Result;
+
+public class CustomScroller extends RecyclerView.OnScrollListener {
+
+    private final Callback callback;
+    private boolean loading;
+    private boolean enable;
+    private int page;
+
+    public CustomScroller(Callback callback) {
+        this.callback = callback;
+        this.enable = true;
+        this.page = 1;
+    }
+
+    @Override
+    public void onScrollStateChanged(@NonNull RecyclerView view, int newState) {
+        if (isDisable() || isLoading() || newState != RecyclerView.SCROLL_STATE_IDLE) return;
+        if (isBottom(view)) callback.onLoadMore(String.valueOf(++page));
+    }
+
+    private boolean isBottom(RecyclerView view) {
+        if (view == null || view.getLayoutManager() == null || view.getLayoutManager().getItemCount() == 0) return false;
+        View lastChild = view.getLayoutManager().getChildAt(view.getLayoutManager().getChildCount() - 1);
+        return lastChild != null && view.getLayoutManager().getPosition(lastChild) == view.getLayoutManager().getItemCount() - 1;
+    }
+
+    public void reset() {
+        page = 1;
+    }
+
+    public int addPage() {
+        return ++page;
+    }
+
+    public boolean first() {
+        return page == 1;
+    }
+
+    public boolean isLoading() {
+        return loading;
+    }
+
+    public void setLoading(boolean loading) {
+        this.loading = loading;
+    }
+
+    public boolean isDisable() {
+        return !enable;
+    }
+
+    public void setEnable(int pageCount) {
+        this.enable = page < pageCount || pageCount == 0;
+    }
+
+    public void endLoading(Result result) {
+        if (result.getList().isEmpty()) page--;
+        setEnable(result.getPageCount());
+        setLoading(false);
+    }
+
+    public interface Callback {
+        void onLoadMore(String page);
+    }
+}

+ 38 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomSearchView.java

@@ -0,0 +1,38 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.animation.Animation;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.utils.ResUtil;
+
+public class CustomSearchView extends CustomEditText {
+
+    private Animation flicker;
+
+    public CustomSearchView(@NonNull Context context) {
+        super(context);
+    }
+
+    public CustomSearchView(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super(context, attrs);
+        flicker = ResUtil.getAnim(R.anim.flicker);
+    }
+
+    @Override
+    public boolean isFocused() {
+        return true;
+    }
+
+    @Override
+    protected void onFocusChanged(boolean gainFocus, int direction, @Nullable Rect previouslyFocusedRect) {
+        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
+        if (gainFocus) startAnimation(flicker);
+        else clearAnimation();
+    }
+}

+ 72 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomSelector.java

@@ -0,0 +1,72 @@
+package com.fongmi.android.tv.ui.custom;
+
+import androidx.annotation.NonNull;
+import androidx.collection.ArrayMap;
+import androidx.leanback.widget.ListRow;
+import androidx.leanback.widget.Presenter;
+import androidx.leanback.widget.PresenterSelector;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+public class CustomSelector extends PresenterSelector {
+
+    private final Set<Presenter> presenters = new LinkedHashSet<>();
+    private final ArrayMap<Key, Presenter> cacheMap = new ArrayMap<>();
+    private final ArrayMap<Class<?>, Presenter> singleMap = new ArrayMap<>();
+    private final ArrayMap<Class<?>, ArrayMap<Class<? extends Presenter>, Presenter>> nestedMap = new ArrayMap<>();
+
+    public void addPresenter(Class<?> cls, Presenter presenter) {
+        singleMap.put(cls, presenter);
+        presenters.add(presenter);
+        cacheMap.clear();
+    }
+
+    public void addPresenter(Class<?> cls, Presenter presenter, Class<? extends Presenter> childType) {
+        nestedMap.computeIfAbsent(cls, k -> new ArrayMap<>()).put(childType, presenter);
+        presenters.add(presenter);
+        cacheMap.clear();
+    }
+
+    @Override
+    public Presenter getPresenter(Object item) {
+        if (item == null) return null;
+        Class<?> cls = item.getClass();
+        Presenter presenter = singleMap.get(cls);
+        if (presenter != null) return presenter;
+        ArrayMap<Class<? extends Presenter>, Presenter> map = nestedMap.get(cls);
+        if (map == null || map.isEmpty()) return null;
+        if (map.size() == 1) {
+            Presenter only = map.valueAt(0);
+            Class<? extends Presenter> childType = map.keyAt(0);
+            cacheMap.put(new Key(cls, childType), only);
+            return only;
+        }
+        if (item instanceof ListRow row) {
+            if (row.getAdapter() == null) return null;
+            Presenter child = row.getAdapter().getPresenter(row);
+            if (child == null) return null;
+            Class<?> childCls = child.getClass();
+            Key key = new Key(cls, childCls);
+            Presenter cached = cacheMap.get(key);
+            if (cached != null) return cached;
+            for (Class<?> type = childCls; type != null && type != Object.class; type = type.getSuperclass()) {
+                Presenter found = map.get(type);
+                if (found != null) {
+                    cacheMap.put(key, found);
+                    return found;
+                }
+            }
+        }
+        return null;
+    }
+
+    @NonNull
+    @Override
+    public Presenter[] getPresenters() {
+        return presenters.toArray(new Presenter[0]);
+    }
+
+    private record Key(Class<?> parent, Class<?> child) {
+    }
+}

+ 84 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTextListener.java

@@ -0,0 +1,84 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.os.Bundle;
+import android.speech.RecognitionListener;
+import android.speech.SpeechRecognizer;
+import android.text.Editable;
+import android.text.TextWatcher;
+
+import java.util.Collections;
+import java.util.List;
+
+public abstract class CustomTextListener implements TextWatcher, RecognitionListener {
+
+    private Runnable done;
+
+    public void setDone(Runnable done) {
+        this.done = done;
+    }
+
+    private void done() {
+        if (done != null) done.run();
+    }
+
+    private String parse(Bundle bundle) {
+        List<String> texts = bundle == null ? Collections.emptyList() : bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
+        if (texts == null || texts.isEmpty()) return "";
+        return texts.get(0).trim();
+    }
+
+    @Override
+    public void onReadyForSpeech(Bundle params) {
+    }
+
+    @Override
+    public void onBeginningOfSpeech() {
+    }
+
+    @Override
+    public void onRmsChanged(float rmsdB) {
+    }
+
+    @Override
+    public void onBufferReceived(byte[] buffer) {
+    }
+
+    @Override
+    public void onEndOfSpeech() {
+    }
+
+    @Override
+    public void onPartialResults(Bundle partialResults) {
+    }
+
+    @Override
+    public void onEvent(int eventType, Bundle params) {
+    }
+
+    @Override
+    public void onError(int error) {
+        done();
+        onResults("");
+    }
+
+    @Override
+    public void onResults(Bundle results) {
+        done();
+        onResults(parse(results));
+    }
+
+    @Override
+    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+    }
+
+    @Override
+    public void onTextChanged(CharSequence s, int start, int before, int count) {
+    }
+
+    @Override
+    public void afterTextChanged(Editable s) {
+    }
+
+    public void onResults(String result) {
+    }
+}

+ 97 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTitleView.java

@@ -0,0 +1,97 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.animation.Animation;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.AppCompatTextView;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.bean.Site;
+import com.fongmi.android.tv.impl.SiteCallback;
+import com.fongmi.android.tv.utils.KeyUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+
+import java.util.List;
+
+public class CustomTitleView extends AppCompatTextView {
+
+    private Listener listener;
+    private Animation flicker;
+    private boolean coolDown;
+
+    private Site getHome() {
+        return VodConfig.get().getHome();
+    }
+
+    public CustomTitleView(@NonNull Context context) {
+        super(context);
+    }
+
+    public CustomTitleView(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super(context, attrs);
+        flicker = ResUtil.getAnim(R.anim.flicker);
+    }
+
+    public void setListener(Listener listener) {
+        this.listener = listener;
+        setOnClickListener(v -> listener.showDialog());
+    }
+
+    private boolean hasEvent(KeyEvent event) {
+        return !getHome().isEmpty() && (KeyUtil.isLeftKey(event) || KeyUtil.isRightKey(event) || (KeyUtil.isUpKey(event) && !coolDown));
+    }
+
+    @Override
+    protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
+        super.onFocusChanged(focused, direction, previouslyFocusedRect);
+        if (focused) startAnimation(flicker);
+        else clearAnimation();
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (!hasEvent(event)) return super.dispatchKeyEvent(event);
+        onKeyDown(event);
+        return true;
+    }
+
+    private void onKeyDown(KeyEvent event) {
+        if (KeyUtil.isActionDown(event) && KeyUtil.isUpKey(event)) onKeyUp();
+        else if (KeyUtil.isActionDown(event) && KeyUtil.isLeftKey(event)) listener.setSite(getSite(false));
+        else if (KeyUtil.isActionDown(event) && KeyUtil.isRightKey(event)) listener.setSite(getSite(true));
+    }
+
+    private void onKeyUp() {
+        App.post(() -> coolDown = false, 3000);
+        listener.onRefresh();
+        coolDown = true;
+    }
+
+    private Site getSite(boolean next) {
+        List<Site> items = getSites();
+        if (items.isEmpty()) return new Site();
+        int position = items.indexOf(getHome());
+        if (position < 0) position = 0;
+        if (next) position = (position + 1) % items.size();
+        else position = (position - 1 + items.size()) % items.size();
+        return items.get(position);
+    }
+
+    private List<Site> getSites() {
+        return VodConfig.get().getSites().stream().filter(site -> !site.isHide()).toList();
+    }
+
+    public interface Listener extends SiteCallback {
+
+        void showDialog();
+
+        void onRefresh();
+    }
+}

+ 60 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java

@@ -0,0 +1,60 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.AppCompatTextView;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.utils.KeyUtil;
+
+public class CustomTypeView extends AppCompatTextView {
+
+    private Listener listener;
+    private boolean coolDown;
+
+    public CustomTypeView(@NonNull Context context) {
+        super(context);
+    }
+
+    public CustomTypeView(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public void setListener(Listener listener) {
+        this.listener = listener;
+    }
+
+    private boolean hasEvent(KeyEvent event) {
+        return !coolDown && KeyUtil.isActionDown(event) && KeyUtil.isUpKey(event);
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (hasEvent(event)) return onKeyDown();
+        else return super.dispatchKeyEvent(event);
+    }
+
+    private boolean onKeyDown() {
+        App.post(() -> coolDown = false, 3000);
+        listener.onRefresh();
+        coolDown = true;
+        return true;
+    }
+
+    @Override
+    protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
+        super.onFocusChanged(focused, direction, previouslyFocusedRect);
+        App.post(() -> coolDown = false, 500);
+        if (focused) coolDown = true;
+    }
+
+    public interface Listener {
+
+        void onRefresh();
+    }
+}

+ 59 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomUpDownView.java

@@ -0,0 +1,59 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.AppCompatTextView;
+
+import com.fongmi.android.tv.utils.KeyUtil;
+
+public class CustomUpDownView extends AppCompatTextView {
+
+    private UpListener upListener;
+    private DownListener downListener;
+
+    public CustomUpDownView(@NonNull Context context) {
+        super(context);
+    }
+
+    public CustomUpDownView(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public void setUpListener(UpListener upListener) {
+        this.upListener = upListener;
+    }
+
+    public void setDownListener(DownListener downListener) {
+        this.downListener = downListener;
+    }
+
+    private boolean hasEvent(KeyEvent event) {
+        return KeyUtil.isActionDown(event) && ((upListener != null && KeyUtil.isUpKey(event)) || (downListener != null && KeyUtil.isDownKey(event)));
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (hasEvent(event)) return onKeyDown(event);
+        else return super.dispatchKeyEvent(event);
+    }
+
+    private boolean onKeyDown(KeyEvent event) {
+        if (upListener != null && KeyUtil.isUpKey(event)) upListener.onUp();
+        if (downListener != null && KeyUtil.isDownKey(event)) downListener.onDown();
+        return true;
+    }
+
+    public interface UpListener {
+
+        void onUp();
+    }
+
+    public interface DownListener {
+
+        void onDown();
+    }
+}

+ 90 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomVerticalGridView.java

@@ -0,0 +1,90 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
+import androidx.leanback.widget.OnChildViewHolderSelectedListener;
+import androidx.leanback.widget.VerticalGridView;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.R;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+public class CustomVerticalGridView extends VerticalGridView {
+
+    private List<View> views;
+    private boolean pressDown;
+    private boolean pressUp;
+    private boolean moveTop;
+
+    public CustomVerticalGridView(@NonNull Context context) {
+        this(context, null);
+    }
+
+    public CustomVerticalGridView(@NonNull Context context, @Nullable AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public CustomVerticalGridView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+        setMoveTop(true);
+    }
+
+    @Override
+    protected void initAttributes(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super.initAttributes(context, attrs);
+        setOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() {
+            @Override
+            public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable ViewHolder child, int position, int subposition) {
+                if (pressDown && position == 1) hideHeader();
+                if (pressUp && position == 0) showHeader();
+            }
+        });
+    }
+
+    public void setHeader(FragmentActivity activity, int... layoutIds) {
+        if (activity != null) views = Arrays.stream(layoutIds).mapToObj(id -> (View) activity.findViewById(id)).filter(Objects::nonNull).toList();
+    }
+
+    public void setMoveTop(boolean moveTop) {
+        this.moveTop = moveTop;
+    }
+
+    public void hideHeader() {
+        if (views != null) for (View view : views) view.setVisibility(View.GONE);
+    }
+
+    public void showHeader() {
+        if (views != null) for (View view : views) view.setVisibility(View.VISIBLE);
+    }
+
+    public boolean isHeaderVisible() {
+        if (views != null) for (View view : views) if (view.getId() == R.id.recycler && view.getVisibility() == View.VISIBLE) return true;
+        return false;
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (event.getAction() != KeyEvent.ACTION_DOWN) return super.dispatchKeyEvent(event);
+        if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) return moveTop && moveToTop();
+        pressUp = event.getKeyCode() == KeyEvent.KEYCODE_DPAD_UP;
+        pressDown = event.getKeyCode() == KeyEvent.KEYCODE_DPAD_DOWN;
+        return super.dispatchKeyEvent(event);
+    }
+
+    public boolean moveToTop() {
+        if (views == null || getSelectedPosition() == 0 || getAdapter() == null || getAdapter().getItemCount() == 0) return false;
+        for (View view : views) if (view.getId() == R.id.recycler) view.requestFocus();
+        scrollToPosition(0);
+        showHeader();
+        return true;
+    }
+}

+ 24 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomViewPager.java

@@ -0,0 +1,24 @@
+package com.fongmi.android.tv.ui.custom;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.viewpager.widget.ViewPager;
+
+public class CustomViewPager extends ViewPager {
+
+    public CustomViewPager(@NonNull Context context) {
+        super(context);
+    }
+
+    public CustomViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    @Override
+    public void setCurrentItem(int item) {
+        super.setCurrentItem(item, false);
+    }
+}

+ 53 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/BufferDialog.java

@@ -0,0 +1,53 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.view.LayoutInflater;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.FragmentActivity;
+
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.databinding.DialogBufferBinding;
+import com.fongmi.android.tv.impl.BufferCallback;
+import com.fongmi.android.tv.utils.KeyUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+public class BufferDialog {
+
+    private final DialogBufferBinding binding;
+    private final BufferCallback callback;
+    private final AlertDialog dialog;
+
+    public static BufferDialog create(FragmentActivity activity) {
+        return new BufferDialog(activity);
+    }
+
+    public BufferDialog(FragmentActivity activity) {
+        this.callback = (BufferCallback) activity;
+        this.binding = DialogBufferBinding.inflate(LayoutInflater.from(activity));
+        this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+    }
+
+    public void show() {
+        initDialog();
+        initView();
+        initEvent();
+    }
+
+    private void initDialog() {
+        dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
+        dialog.show();
+    }
+
+    private void initView() {
+        binding.slider.setValue(Setting.getBuffer());
+    }
+
+    private void initEvent() {
+        binding.slider.addOnChangeListener((slider, value, fromUser) -> callback.setBuffer((int) value));
+        binding.slider.setOnKeyListener((view, keyCode, event) -> {
+            boolean enter = KeyUtil.isEnterKey(event);
+            if (enter) dialog.dismiss();
+            return enter;
+        });
+    }
+}

+ 172 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java

@@ -0,0 +1,172 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.WindowManager;
+import android.view.inputmethod.EditorInfo;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.FragmentActivity;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.api.config.LiveConfig;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.api.config.WallConfig;
+import com.fongmi.android.tv.bean.Config;
+import com.fongmi.android.tv.databinding.DialogConfigBinding;
+import com.fongmi.android.tv.event.ServerEvent;
+import com.fongmi.android.tv.impl.ConfigCallback;
+import com.fongmi.android.tv.server.Server;
+import com.fongmi.android.tv.ui.custom.CustomTextListener;
+import com.fongmi.android.tv.utils.FileChooser;
+import com.fongmi.android.tv.utils.QRCode;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.greenrobot.eventbus.EventBus;
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+public class ConfigDialog implements DialogInterface.OnDismissListener {
+
+    private ActivityResultLauncher<Intent> launcher;
+    private final DialogConfigBinding binding;
+    private final ConfigCallback callback;
+    private final AlertDialog dialog;
+    private boolean append;
+    private boolean edit;
+    private String url;
+    private int type;
+
+    public static ConfigDialog create(FragmentActivity activity) {
+        return new ConfigDialog(activity);
+    }
+
+    public ConfigDialog type(int type) {
+        this.type = type;
+        return this;
+    }
+
+    public ConfigDialog edit() {
+        this.edit = true;
+        return this;
+    }
+
+    public ConfigDialog launcher(ActivityResultLauncher<Intent> launcher) {
+        this.launcher = launcher;
+        return this;
+    }
+
+    public ConfigDialog(FragmentActivity activity) {
+        this.callback = (ConfigCallback) activity;
+        this.binding = DialogConfigBinding.inflate(LayoutInflater.from(activity));
+        this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+        this.append = true;
+    }
+
+    public void show() {
+        initDialog();
+        initView();
+        initEvent();
+    }
+
+    private void initDialog() {
+        WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
+        params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
+        dialog.getWindow().setAttributes(params);
+        dialog.getWindow().setDimAmount(0);
+        dialog.setOnDismissListener(this);
+        dialog.show();
+    }
+
+    private void initView() {
+        binding.text.setText(url = getUrl());
+        binding.text.setSelection(TextUtils.isEmpty(url) ? 0 : url.length());
+        binding.positive.setText(edit ? R.string.dialog_edit : R.string.dialog_positive);
+        binding.code.setImageBitmap(QRCode.getBitmap(Server.get().getAddress(3), 200, 0));
+        binding.info.setText(ResUtil.getString(R.string.push_info, Server.get().getAddress()).replace(",", "\n"));
+    }
+
+    private void initEvent() {
+        EventBus.getDefault().register(this);
+        binding.choose.setOnClickListener(this::onChoose);
+        binding.positive.setOnClickListener(this::onPositive);
+        binding.negative.setOnClickListener(this::onNegative);
+        binding.text.addTextChangedListener(new CustomTextListener() {
+            @Override
+            public void onTextChanged(CharSequence s, int start, int before, int count) {
+                detect(s.toString());
+            }
+        });
+        binding.text.setOnEditorActionListener((textView, actionId, event) -> {
+            if (actionId == EditorInfo.IME_ACTION_DONE) binding.positive.performClick();
+            return true;
+        });
+    }
+
+    private String getUrl() {
+        switch (type) {
+            case 0:
+                return VodConfig.getUrl();
+            case 1:
+                return LiveConfig.getUrl();
+            case 2:
+                return WallConfig.getUrl();
+            default:
+                return "";
+        }
+    }
+
+    private void onChoose(View view) {
+        FileChooser.from(launcher).show();
+        dialog.dismiss();
+    }
+
+    private void detect(String s) {
+        if (append && "h".equalsIgnoreCase(s)) {
+            append = false;
+            binding.text.append("ttp://");
+        } else if (append && "f".equalsIgnoreCase(s)) {
+            append = false;
+            binding.text.append("ile://");
+        } else if (append && "a".equalsIgnoreCase(s)) {
+            append = false;
+            binding.text.append("ssets://");
+        } else if (s.length() > 1) {
+            append = false;
+        } else if (s.isEmpty()) {
+            append = true;
+        }
+    }
+
+    private void onPositive(View view) {
+        String name = binding.name.getText().toString().trim();
+        String text = binding.text.getText().toString().trim();
+        if (edit) Config.find(url, type).url(text).update();
+        if (text.isEmpty()) Config.delete(url, type);
+        if (name.isEmpty()) callback.setConfig(Config.find(text, type));
+        else callback.setConfig(Config.find(text, name, type));
+        dialog.dismiss();
+    }
+
+    private void onNegative(View view) {
+        dialog.dismiss();
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onServerEvent(ServerEvent event) {
+        if (event.type() != ServerEvent.Type.SETTING) return;
+        binding.name.setText(event.name());
+        binding.text.setText(event.text());
+        binding.text.setSelection(binding.text.getText().length());
+    }
+
+    @Override
+    public void onDismiss(DialogInterface dialogInterface) {
+        EventBus.getDefault().unregister(this);
+    }
+}

+ 33 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ContentDialog.java

@@ -0,0 +1,33 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.app.Activity;
+import android.view.LayoutInflater;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.fongmi.android.tv.databinding.DialogContentBinding;
+import com.fongmi.android.tv.ui.custom.CustomMovement;
+import com.github.bassaer.library.MDColor;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+public class ContentDialog {
+
+    public static void show(Activity activity, CharSequence content) {
+        new ContentDialog().create(activity, content);
+    }
+
+    public void create(Activity activity, CharSequence content) {
+        DialogContentBinding binding = DialogContentBinding.inflate(LayoutInflater.from(activity));
+        AlertDialog dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+        dialog.getWindow().setDimAmount(0);
+        initView(binding.text, content);
+        dialog.show();
+    }
+
+    private void initView(TextView view, CharSequence content) {
+        view.setText(content, TextView.BufferType.SPANNABLE);
+        view.setLinkTextColor(MDColor.BLUE_500);
+        CustomMovement.bind(view);
+    }
+}

+ 66 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DohDialog.java

@@ -0,0 +1,66 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.app.Activity;
+import android.view.LayoutInflater;
+import android.view.WindowManager;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.fongmi.android.tv.databinding.DialogDohBinding;
+import com.fongmi.android.tv.impl.DohCallback;
+import com.fongmi.android.tv.ui.adapter.DohAdapter;
+import com.fongmi.android.tv.ui.custom.SpaceItemDecoration;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.github.catvod.bean.Doh;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+public class DohDialog implements DohAdapter.OnClickListener {
+
+    private final DialogDohBinding binding;
+    private final DohCallback callback;
+    private final AlertDialog dialog;
+    private final DohAdapter adapter;
+
+    public static DohDialog create(Activity activity) {
+        return new DohDialog(activity);
+    }
+
+    public DohDialog index(int index) {
+        adapter.setSelect(index);
+        return this;
+    }
+
+    public DohDialog(Activity activity) {
+        this.callback = (DohCallback) activity;
+        this.binding = DialogDohBinding.inflate(LayoutInflater.from(activity));
+        this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+        this.adapter = new DohAdapter(this);
+    }
+
+    public void show() {
+        setRecyclerView();
+        setDialog();
+    }
+
+    private void setRecyclerView() {
+        binding.recycler.setAdapter(adapter);
+        binding.recycler.setHasFixedSize(true);
+        binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 16));
+        binding.recycler.post(() -> binding.recycler.scrollToPosition(adapter.getSelect()));
+    }
+
+    private void setDialog() {
+        if (adapter.getItemCount() == 0) return;
+        WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
+        params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
+        dialog.getWindow().setAttributes(params);
+        dialog.getWindow().setDimAmount(0);
+        dialog.show();
+    }
+
+    @Override
+    public void onItemClick(Doh item) {
+        callback.setDoh(item);
+        dialog.dismiss();
+    }
+}

+ 78 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java

@@ -0,0 +1,78 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.app.Activity;
+import android.view.LayoutInflater;
+import android.view.WindowManager;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.fongmi.android.tv.bean.Config;
+import com.fongmi.android.tv.databinding.DialogHistoryBinding;
+import com.fongmi.android.tv.impl.ConfigCallback;
+import com.fongmi.android.tv.ui.adapter.ConfigAdapter;
+import com.fongmi.android.tv.ui.custom.SpaceItemDecoration;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+public class HistoryDialog implements ConfigAdapter.OnClickListener {
+
+    private final DialogHistoryBinding binding;
+    private final ConfigCallback callback;
+    private final AlertDialog dialog;
+    private ConfigAdapter adapter;
+    private boolean readOnly;
+    private int type;
+
+    public static HistoryDialog create(Activity activity) {
+        return new HistoryDialog(activity);
+    }
+
+    public HistoryDialog type(int type) {
+        this.type = type;
+        return this;
+    }
+
+    public HistoryDialog(Activity activity) {
+        this.callback = (ConfigCallback) activity;
+        this.binding = DialogHistoryBinding.inflate(LayoutInflater.from(activity));
+        this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+    }
+
+    public HistoryDialog readOnly() {
+        this.readOnly = true;
+        return this;
+    }
+
+    public void show() {
+        setRecyclerView();
+        setDialog();
+    }
+
+    private void setRecyclerView() {
+        adapter = new ConfigAdapter(this);
+        binding.recycler.setItemAnimator(null);
+        binding.recycler.setHasFixedSize(false);
+        binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 16));
+        binding.recycler.setAdapter(adapter.readOnly(readOnly).addAll(type));
+    }
+
+    private void setDialog() {
+        if (adapter.getItemCount() == 0) return;
+        WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
+        params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
+        dialog.getWindow().setAttributes(params);
+        dialog.getWindow().setDimAmount(0);
+        dialog.show();
+    }
+
+    @Override
+    public void onTextClick(Config item) {
+        callback.setConfig(item);
+        dialog.dismiss();
+    }
+
+    @Override
+    public void onDeleteClick(Config item) {
+        if (adapter.remove(item) == 0) dialog.dismiss();
+    }
+}

+ 96 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/LiveDialog.java

@@ -0,0 +1,96 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.app.Activity;
+import android.view.LayoutInflater;
+import android.view.WindowManager;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.fongmi.android.tv.api.config.LiveConfig;
+import com.fongmi.android.tv.bean.Live;
+import com.fongmi.android.tv.databinding.DialogLiveBinding;
+import com.fongmi.android.tv.impl.LiveCallback;
+import com.fongmi.android.tv.ui.adapter.LiveAdapter;
+import com.fongmi.android.tv.ui.custom.SpaceItemDecoration;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+public class LiveDialog implements LiveAdapter.OnClickListener {
+
+    private final DialogLiveBinding binding;
+    private final LiveCallback callback;
+    private final LiveAdapter adapter;
+    private final AlertDialog dialog;
+
+    public static LiveDialog create(Activity activity) {
+        return new LiveDialog(activity);
+    }
+
+    private LiveDialog(Activity activity) {
+        this.adapter = new LiveAdapter(this);
+        this.callback = (LiveCallback) activity;
+        this.binding = DialogLiveBinding.inflate(LayoutInflater.from(activity));
+        this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+    }
+
+    public LiveDialog action() {
+        adapter.setAction(true);
+        return this;
+    }
+
+    public void show() {
+        setRecyclerView();
+        setDialog();
+    }
+
+    private void setRecyclerView() {
+        binding.recycler.setAdapter(adapter);
+        binding.recycler.setHasFixedSize(true);
+        binding.recycler.setItemAnimator(null);
+        binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 16));
+        binding.recycler.post(() -> binding.recycler.scrollToPosition(LiveConfig.getHomeIndex()));
+    }
+
+    private void setDialog() {
+        if (adapter.getItemCount() == 0) return;
+        WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
+        params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
+        dialog.getWindow().setAttributes(params);
+        dialog.getWindow().setDimAmount(0);
+        dialog.show();
+    }
+
+    @Override
+    public void onItemClick(Live item) {
+        callback.setLive(item);
+        dialog.dismiss();
+    }
+
+    @Override
+    public void onBootClick(int position, Live item) {
+        item.boot(!item.isBoot()).save();
+        adapter.notifyItemChanged(position);
+    }
+
+    @Override
+    public void onPassClick(int position, Live item) {
+        item.pass(!item.isPass()).save();
+        adapter.notifyItemChanged(position);
+    }
+
+    @Override
+    public boolean onBootLongClick(Live item) {
+        boolean result = !item.isBoot();
+        for (Live live : LiveConfig.get().getLives()) live.boot(result).save();
+        adapter.notifyItemRangeChanged(0, adapter.getItemCount());
+        return true;
+    }
+
+    @Override
+    public boolean onPassLongClick(Live item) {
+        boolean result = !item.isPass();
+        for (Live live : LiveConfig.get().getLives()) live.pass(result).save();
+        adapter.notifyItemRangeChanged(0, adapter.getItemCount());
+        return true;
+    }
+}

+ 63 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PassDialog.java

@@ -0,0 +1,63 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.databinding.DialogPassBinding;
+import com.fongmi.android.tv.impl.PassCallback;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
+
+public class PassDialog extends BaseDialog {
+
+    private DialogPassBinding binding;
+    private PassCallback callback;
+
+    public static PassDialog create() {
+        return new PassDialog();
+    }
+
+    public void show(FragmentActivity activity) {
+        for (Fragment f : activity.getSupportFragmentManager().getFragments()) if (f instanceof BottomSheetDialogFragment) return;
+        show(activity.getSupportFragmentManager(), null);
+        this.callback = (PassCallback) activity;
+    }
+
+    @Override
+    protected ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) {
+        return binding = DialogPassBinding.inflate(inflater, container, false);
+    }
+
+    @Override
+    protected void initEvent() {
+        binding.positive.setOnClickListener(this::onPass);
+        binding.pass.setOnEditorActionListener(this::onDone);
+    }
+
+    private void onPass(View view) {
+        String pass = binding.pass.getText().toString().trim();
+        if (pass.length() > 0) callback.setPass(pass);
+        dismiss();
+    }
+
+    private boolean onDone(TextView view, int actionId, KeyEvent event) {
+        if (actionId == EditorInfo.IME_ACTION_DONE) binding.positive.performClick();
+        return true;
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        getDialog().getWindow().setLayout(ResUtil.dp2px(250), -1);
+    }
+}

+ 68 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/RestoreDialog.java

@@ -0,0 +1,68 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.app.Activity;
+import android.view.LayoutInflater;
+import android.view.WindowManager;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.fongmi.android.tv.databinding.DialogRestoreBinding;
+import com.fongmi.android.tv.db.AppDatabase;
+import com.fongmi.android.tv.impl.Callback;
+import com.fongmi.android.tv.ui.adapter.RestoreAdapter;
+import com.fongmi.android.tv.ui.custom.SpaceItemDecoration;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.io.File;
+
+public class RestoreDialog implements RestoreAdapter.OnClickListener {
+
+    private final DialogRestoreBinding binding;
+    private final RestoreAdapter adapter;
+    private final AlertDialog dialog;
+    private Callback callback;
+
+    public static RestoreDialog create(Activity activity) {
+        return new RestoreDialog(activity);
+    }
+
+    public RestoreDialog(Activity activity) {
+        this.binding = DialogRestoreBinding.inflate(LayoutInflater.from(activity));
+        this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+        this.adapter = new RestoreAdapter(this);
+    }
+
+    public void show(Callback callback) {
+        this.callback = callback;
+        setRecyclerView();
+        setDialog();
+    }
+
+    private void setRecyclerView() {
+        binding.recycler.setAdapter(adapter);
+        binding.recycler.setItemAnimator(null);
+        binding.recycler.setHasFixedSize(false);
+        binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 16));
+    }
+
+    private void setDialog() {
+        if (adapter.getItemCount() == 0) return;
+        WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
+        params.width = (int) (ResUtil.getScreenWidth() * 0.4f);
+        dialog.getWindow().setAttributes(params);
+        dialog.getWindow().setDimAmount(0);
+        dialog.show();
+    }
+
+    @Override
+    public void onItemClick(File item) {
+        AppDatabase.restore(item, callback);
+        dialog.dismiss();
+    }
+
+    @Override
+    public void onDeleteClick(File item) {
+        if (adapter.remove(item) == 0) dialog.dismiss();
+    }
+}

+ 135 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java

@@ -0,0 +1,135 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.app.Activity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.WindowManager;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.bean.Site;
+import com.fongmi.android.tv.databinding.DialogSiteBinding;
+import com.fongmi.android.tv.impl.SiteCallback;
+import com.fongmi.android.tv.ui.adapter.SiteAdapter;
+import com.fongmi.android.tv.ui.custom.SpaceItemDecoration;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+public class SiteDialog implements SiteAdapter.OnClickListener {
+
+    private final DialogSiteBinding binding;
+    private final SiteAdapter adapter;
+    private final AlertDialog dialog;
+    private final int GRID_COUNT = 10;
+
+    private RecyclerView.ItemDecoration decoration;
+    private SiteCallback callback;
+    private int type;
+
+    public static SiteDialog create(Activity activity) {
+        return new SiteDialog(activity);
+    }
+
+    public SiteDialog(Activity activity) {
+        this.adapter = new SiteAdapter(this);
+        this.binding = DialogSiteBinding.inflate(LayoutInflater.from(activity));
+        this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+        if (activity instanceof SiteCallback) this.callback = (SiteCallback) activity;
+    }
+
+    public SiteDialog search() {
+        type = 1;
+        return this;
+    }
+
+    public SiteDialog action() {
+        binding.action.setVisibility(View.VISIBLE);
+        return this;
+    }
+
+    public void show() {
+        setType(type);
+        initView();
+        initEvent();
+    }
+
+    private boolean list() {
+        return Setting.getSiteMode() == 0 || adapter.getItemCount() < GRID_COUNT;
+    }
+
+    private int getCount() {
+        return list() ? 1 : Math.max(2, Math.min((int) Math.ceil((double) adapter.getItemCount() / GRID_COUNT), 3));
+    }
+
+    private int getIcon() {
+        return list() ? R.drawable.ic_site_grid : R.drawable.ic_site_list;
+    }
+
+    private float getWidth() {
+        return 0.4f + (getCount() - 1) * 0.2f;
+    }
+
+    private void initView() {
+        setRecyclerView();
+        setDialog();
+        setMode();
+    }
+
+    private void initEvent() {
+        binding.mode.setOnClickListener(this::setMode);
+        binding.select.setOnClickListener(v -> adapter.selectAll());
+        binding.cancel.setOnClickListener(v -> adapter.cancelAll());
+        binding.search.setOnClickListener(v -> setType(v.isActivated() ? 0 : 1));
+        binding.change.setOnClickListener(v -> setType(v.isActivated() ? 0 : 2));
+    }
+
+    private void setRecyclerView() {
+        binding.recycler.setAdapter(adapter);
+        binding.recycler.setHasFixedSize(true);
+        binding.recycler.setItemAnimator(null);
+        if (decoration != null) binding.recycler.removeItemDecoration(decoration);
+        binding.recycler.addItemDecoration(decoration = new SpaceItemDecoration(getCount(), 16));
+        binding.recycler.setLayoutManager(new GridLayoutManager(dialog.getContext(), getCount()));
+        if (!binding.mode.hasFocus()) binding.recycler.post(() -> binding.recycler.scrollToPosition(VodConfig.getHomeIndex()));
+    }
+
+    private void setDialog() {
+        if (adapter.getItemCount() == 0) return;
+        WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
+        params.width = (int) (ResUtil.getScreenWidth() * getWidth());
+        dialog.getWindow().setAttributes(params);
+        dialog.getWindow().setDimAmount(0);
+        dialog.show();
+    }
+
+    private void setMode() {
+        if (adapter.getItemCount() < GRID_COUNT) Setting.putSiteMode(0);
+        binding.mode.setEnabled(adapter.getItemCount() >= GRID_COUNT);
+        binding.mode.setImageResource(getIcon());
+    }
+
+    private void setType(int type) {
+        binding.search.setActivated(type == 1);
+        binding.change.setActivated(type == 2);
+        binding.select.setClickable(type > 0);
+        binding.cancel.setClickable(type > 0);
+        adapter.setType(this.type = type);
+    }
+
+    private void setMode(View view) {
+        Setting.putSiteMode(Math.abs(Setting.getSiteMode() - 1));
+        initView();
+    }
+
+    @Override
+    public void onItemClick(Site item) {
+        if (callback == null) return;
+        callback.setSite(item);
+        dialog.dismiss();
+    }
+}

+ 53 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SpeedDialog.java

@@ -0,0 +1,53 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.view.LayoutInflater;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.FragmentActivity;
+
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.databinding.DialogSpeedBinding;
+import com.fongmi.android.tv.impl.SpeedCallback;
+import com.fongmi.android.tv.utils.KeyUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+public class SpeedDialog {
+
+    private final DialogSpeedBinding binding;
+    private final SpeedCallback callback;
+    private final AlertDialog dialog;
+
+    public static SpeedDialog create(FragmentActivity activity) {
+        return new SpeedDialog(activity);
+    }
+
+    public SpeedDialog(FragmentActivity activity) {
+        this.callback = (SpeedCallback) activity;
+        this.binding = DialogSpeedBinding.inflate(LayoutInflater.from(activity));
+        this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+    }
+
+    public void show() {
+        initDialog();
+        initView();
+        initEvent();
+    }
+
+    private void initDialog() {
+        dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
+        dialog.show();
+    }
+
+    private void initView() {
+        binding.slider.setValue(Setting.getSpeed());
+    }
+
+    private void initEvent() {
+        binding.slider.addOnChangeListener((slider, value, fromUser) -> callback.setSpeed(value));
+        binding.slider.setOnKeyListener((view, keyCode, event) -> {
+            boolean enter = KeyUtil.isEnterKey(event);
+            if (enter) dialog.dismiss();
+            return enter;
+        });
+    }
+}

+ 120 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UaDialog.java

@@ -0,0 +1,120 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.content.DialogInterface;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.WindowManager;
+import android.view.inputmethod.EditorInfo;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.FragmentActivity;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.Setting;
+import com.fongmi.android.tv.databinding.DialogUaBinding;
+import com.fongmi.android.tv.event.ServerEvent;
+import com.fongmi.android.tv.impl.UaCallback;
+import com.fongmi.android.tv.server.Server;
+import com.fongmi.android.tv.ui.custom.CustomTextListener;
+import com.fongmi.android.tv.utils.QRCode;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.github.catvod.utils.Util;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.greenrobot.eventbus.EventBus;
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
+public class UaDialog implements DialogInterface.OnDismissListener {
+
+    private final DialogUaBinding binding;
+    private final UaCallback callback;
+    private final AlertDialog dialog;
+    private boolean append;
+
+    public static UaDialog create(FragmentActivity activity) {
+        return new UaDialog(activity);
+    }
+
+    public UaDialog(FragmentActivity activity) {
+        this.callback = (UaCallback) activity;
+        this.binding = DialogUaBinding.inflate(LayoutInflater.from(activity));
+        this.dialog = new MaterialAlertDialogBuilder(activity).setView(binding.getRoot()).create();
+        this.append = true;
+    }
+
+    public void show() {
+        initDialog();
+        initView();
+        initEvent();
+    }
+
+    private void initDialog() {
+        WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
+        params.width = (int) (ResUtil.getScreenWidth() * 0.55f);
+        dialog.getWindow().setAttributes(params);
+        dialog.getWindow().setDimAmount(0);
+        dialog.setOnDismissListener(this);
+        dialog.show();
+    }
+
+    private void initView() {
+        String text = Setting.getUa();
+        binding.text.setText(text);
+        binding.text.setSelection(TextUtils.isEmpty(text) ? 0 : text.length());
+        binding.code.setImageBitmap(QRCode.getBitmap(Server.get().getAddress(3), 200, 0));
+        binding.info.setText(ResUtil.getString(R.string.push_info, Server.get().getAddress()).replace(",", "\n"));
+    }
+
+    private void initEvent() {
+        EventBus.getDefault().register(this);
+        binding.positive.setOnClickListener(this::onPositive);
+        binding.negative.setOnClickListener(this::onNegative);
+        binding.text.addTextChangedListener(new CustomTextListener() {
+            @Override
+            public void onTextChanged(CharSequence s, int start, int before, int count) {
+                detect(s.toString());
+            }
+        });
+        binding.text.setOnEditorActionListener((textView, actionId, event) -> {
+            if (actionId == EditorInfo.IME_ACTION_DONE) binding.positive.performClick();
+            return true;
+        });
+    }
+
+    private void detect(String s) {
+        if (append && "c".equalsIgnoreCase(s)) {
+            append = false;
+            binding.text.setText(Util.CHROME);
+        } else if (append && "o".equalsIgnoreCase(s)) {
+            append = false;
+            binding.text.setText(Util.OKHTTP);
+        } else if (s.length() > 1) {
+            append = false;
+        } else if (s.isEmpty()) {
+            append = true;
+        }
+    }
+
+    private void onPositive(View view) {
+        callback.setUa(binding.text.getText().toString().trim());
+        dialog.dismiss();
+    }
+
+    private void onNegative(View view) {
+        dialog.dismiss();
+    }
+
+    @Subscribe(threadMode = ThreadMode.MAIN)
+    public void onServerEvent(ServerEvent event) {
+        if (event.type() != ServerEvent.Type.SETTING) return;
+        binding.text.setText(event.text());
+        binding.positive.performClick();
+    }
+
+    @Override
+    public void onDismiss(DialogInterface dialogInterface) {
+        EventBus.getDefault().unregister(this);
+    }
+}

+ 44 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java

@@ -0,0 +1,44 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.content.DialogInterface;
+import android.view.View;
+import android.view.WindowManager;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+public class WebDialog {
+
+    private final AlertDialog dialog;
+
+    public static WebDialog create(View view) {
+        return new WebDialog(view);
+    }
+
+    public WebDialog(View view) {
+        this.dialog = new MaterialAlertDialogBuilder(App.activity()).setView(view).create();
+        this.dialog.setOnDismissListener((DialogInterface.OnDismissListener) view);
+    }
+
+    public WebDialog show() {
+        initDialog();
+        return this;
+    }
+
+    public void dismiss() {
+        dialog.setOnDismissListener(null);
+        dialog.dismiss();
+    }
+
+    private void initDialog() {
+        WindowManager.LayoutParams params = dialog.getWindow().getAttributes();
+        params.height = (int) (ResUtil.getScreenHeight() * 0.8f);
+        params.width = (int) (ResUtil.getScreenWidth() * 0.8f);
+        dialog.getWindow().setAttributes(params);
+        dialog.getWindow().setDimAmount(0);
+        dialog.show();
+    }
+}

+ 142 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java

@@ -0,0 +1,142 @@
+package com.fongmi.android.tv.ui.fragment;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.leanback.widget.ArrayObjectAdapter;
+import androidx.leanback.widget.ItemBridgeAdapter;
+import androidx.leanback.widget.ListRow;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.Product;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.bean.Collect;
+import com.fongmi.android.tv.bean.Result;
+import com.fongmi.android.tv.bean.Vod;
+import com.fongmi.android.tv.databinding.FragmentTypeBinding;
+import com.fongmi.android.tv.model.SiteViewModel;
+import com.fongmi.android.tv.ui.activity.VideoActivity;
+import com.fongmi.android.tv.ui.activity.VodActivity;
+import com.fongmi.android.tv.ui.base.BaseFragment;
+import com.fongmi.android.tv.ui.custom.CustomRowPresenter;
+import com.fongmi.android.tv.ui.custom.CustomScroller;
+import com.fongmi.android.tv.ui.custom.CustomSelector;
+import com.fongmi.android.tv.ui.presenter.VodPresenter;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CollectFragment extends BaseFragment implements CustomScroller.Callback, VodPresenter.OnClickListener {
+
+    private FragmentTypeBinding mBinding;
+    private ArrayObjectAdapter mAdapter;
+    private ArrayObjectAdapter mLast;
+    private CustomScroller mScroller;
+    private SiteViewModel mViewModel;
+    private Collect mCollect;
+    private String mKeyword;
+
+    public static CollectFragment newInstance(String keyword, Collect collect) {
+        Bundle args = new Bundle();
+        args.putString("keyword", keyword);
+        CollectFragment fragment = new CollectFragment().setCollect(collect);
+        fragment.setArguments(args);
+        return fragment;
+    }
+
+    private String getKeyword() {
+        return mKeyword = mKeyword == null ? getArguments().getString("keyword") : mKeyword;
+    }
+
+    private CollectFragment setCollect(Collect collect) {
+        this.mCollect = collect;
+        return this;
+    }
+
+    @Override
+    protected ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) {
+        return mBinding = FragmentTypeBinding.inflate(inflater, container, false);
+    }
+
+    @Override
+    protected void initView() {
+        setRecyclerView();
+        setViewModel();
+        addVideo(mCollect);
+    }
+
+    private void setRecyclerView() {
+        CustomSelector selector = new CustomSelector();
+        selector.addPresenter(ListRow.class, new CustomRowPresenter(16), VodPresenter.class);
+        mBinding.recycler.setAdapter(new ItemBridgeAdapter(mAdapter = new ArrayObjectAdapter(selector)));
+        mBinding.recycler.addOnScrollListener(mScroller = new CustomScroller(this));
+        mBinding.recycler.setHeader(getActivity(), R.id.result, R.id.recycler);
+        mBinding.recycler.setVerticalSpacing(ResUtil.dp2px(16));
+    }
+
+    private void setViewModel() {
+        mViewModel = new ViewModelProvider(this).get(SiteViewModel.class);
+        mViewModel.getResult().observe(this, result -> {
+            mScroller.endLoading(result);
+            addVideo(result.getList());
+        });
+    }
+
+    private boolean checkLastSize(List<Vod> items) {
+        if (mLast == null || items.isEmpty()) return false;
+        int size = Product.getColumn() - mLast.size();
+        if (size == 0) return false;
+        size = Math.min(size, items.size());
+        mLast.addAll(mLast.size(), items.subList(0, size));
+        addVideo(items.subList(size, items.size()));
+        return true;
+    }
+
+    private void addVideo(Collect collect) {
+        if (collect != null) addVideo(collect.getList());
+    }
+
+    public void addVideo(List<Vod> items) {
+        if (checkLastSize(items) || getActivity() == null || getActivity().isFinishing()) return;
+        List<ListRow> rows = new ArrayList<>();
+        VodPresenter presenter = new VodPresenter(this);
+        for (List<Vod> part : Lists.partition(items, Product.getColumn())) {
+            mLast = new ArrayObjectAdapter(presenter);
+            mLast.addAll(0, part);
+            rows.add(new ListRow(mLast));
+        }
+        mAdapter.addAll(mAdapter.size(), rows);
+    }
+
+    @Override
+    public void onItemClick(Vod item) {
+        requireActivity().setResult(Activity.RESULT_OK);
+        if (item.isFolder()) VodActivity.start(requireActivity(), item.getSiteKey(), Result.folder(item));
+        else VideoActivity.collect(requireActivity(), item.getSiteKey(), item.getId(), item.getName(), item.getPic());
+    }
+
+    @Override
+    public boolean onLongClick(Vod item) {
+        return false;
+    }
+
+    @Override
+    public void onLoadMore(String page) {
+        if (mCollect == null || "all".equals(mCollect.getSite().getKey())) return;
+        mViewModel.searchContent(mCollect.getSite(), getKeyword(), false, page);
+        mScroller.setLoading(true);
+    }
+
+    @Override
+    public void setUserVisibleHint(boolean isVisibleToUser) {
+        super.setUserVisibleHint(isVisibleToUser);
+        if (mBinding != null && !isVisibleToUser) mBinding.recycler.moveToTop();
+    }
+}

+ 101 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java

@@ -0,0 +1,101 @@
+package com.fongmi.android.tv.ui.fragment;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentTransaction;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.bean.Cache;
+import com.fongmi.android.tv.bean.Class;
+import com.fongmi.android.tv.bean.Filter;
+import com.fongmi.android.tv.databinding.FragmentFolderBinding;
+import com.fongmi.android.tv.ui.activity.VodActivity;
+import com.fongmi.android.tv.ui.base.BaseFragment;
+
+import java.util.HashMap;
+import java.util.Optional;
+
+public class FolderFragment extends BaseFragment {
+
+    private FragmentFolderBinding mBinding;
+    private Class mType;
+
+    public static FolderFragment newInstance(String key, Class type) {
+        Bundle args = new Bundle();
+        args.putString("key", key);
+        args.putParcelable("type", type);
+        FolderFragment fragment = new FolderFragment();
+        fragment.setArguments(args);
+        return fragment;
+    }
+
+    private String getKey() {
+        return getArguments().getString("key");
+    }
+
+    public Class getType() {
+        return getArguments().getParcelable("type");
+    }
+
+    private TypeFragment getChild() {
+        return (TypeFragment) getChildFragmentManager().findFragmentById(R.id.container);
+    }
+
+    private VodActivity getParent() {
+        return (VodActivity) getActivity();
+    }
+
+    @Override
+    protected ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) {
+        return mBinding = FragmentFolderBinding.inflate(inflater, container, false);
+    }
+
+    @Override
+    protected void initView() {
+        mType = getType();
+        getChildFragmentManager().beginTransaction().replace(R.id.container, TypeFragment.newInstance(getKey(), mType.getTypeId(), mType.getStyle(), getExtend(), mType.isFolder())).commit();
+    }
+
+    private HashMap<String, String> getExtend() {
+        HashMap<String, String> extend = new HashMap<>();
+        for (Filter filter : Cache.get(mType)) if (filter.getInit() != null) extend.put(filter.getKey(), filter.getInit());
+        return extend;
+    }
+
+    public void openFolder(String typeId, HashMap<String, String> extend) {
+        TypeFragment next = TypeFragment.newInstance(getKey(), typeId, mType.getStyle(), extend, mType.isFolder());
+        FragmentTransaction ft = getChildFragmentManager().beginTransaction();
+        Optional.ofNullable(getParent()).ifPresent(VodActivity::closeFilter);
+        Optional.ofNullable(getChild()).ifPresent(ft::hide);
+        ft.add(R.id.container, next);
+        ft.addToBackStack(null);
+        ft.commit();
+    }
+
+    public void toggleFilter(boolean visible) {
+        Optional.ofNullable(getChild()).ifPresent(f -> f.toggleFilter(visible));
+    }
+
+    public void onRefresh() {
+        Optional.ofNullable(getChild()).ifPresent(TypeFragment::onRefresh);
+    }
+
+    public boolean canBack() {
+        return getChildFragmentManager().getBackStackEntryCount() > 0;
+    }
+
+    public void goBack() {
+        getChildFragmentManager().popBackStack();
+    }
+
+    @Override
+    public void setUserVisibleHint(boolean isVisibleToUser) {
+        super.setUserVisibleHint(isVisibleToUser);
+        if (mBinding != null && !isVisibleToUser) Optional.ofNullable(getChild()).ifPresent(f -> f.setUserVisibleHint(false));
+    }
+}

+ 295 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java

@@ -0,0 +1,295 @@
+package com.fongmi.android.tv.ui.fragment;
+
+import android.annotation.SuppressLint;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.leanback.widget.ArrayObjectAdapter;
+import androidx.leanback.widget.FocusHighlight;
+import androidx.leanback.widget.HorizontalGridView;
+import androidx.leanback.widget.ItemBridgeAdapter;
+import androidx.leanback.widget.ListRow;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+import androidx.viewbinding.ViewBinding;
+
+import com.fongmi.android.tv.Product;
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.api.config.VodConfig;
+import com.fongmi.android.tv.bean.Cache;
+import com.fongmi.android.tv.bean.Filter;
+import com.fongmi.android.tv.bean.Result;
+import com.fongmi.android.tv.bean.Site;
+import com.fongmi.android.tv.bean.Style;
+import com.fongmi.android.tv.bean.Value;
+import com.fongmi.android.tv.bean.Vod;
+import com.fongmi.android.tv.databinding.FragmentTypeBinding;
+import com.fongmi.android.tv.model.SiteViewModel;
+import com.fongmi.android.tv.ui.activity.CollectActivity;
+import com.fongmi.android.tv.ui.activity.VideoActivity;
+import com.fongmi.android.tv.ui.base.BaseFragment;
+import com.fongmi.android.tv.ui.custom.CustomRowPresenter;
+import com.fongmi.android.tv.ui.custom.CustomScroller;
+import com.fongmi.android.tv.ui.custom.CustomSelector;
+import com.fongmi.android.tv.ui.presenter.FilterPresenter;
+import com.fongmi.android.tv.ui.presenter.VodPresenter;
+import com.fongmi.android.tv.utils.Notify;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+public class TypeFragment extends BaseFragment implements CustomScroller.Callback, VodPresenter.OnClickListener, SwipeRefreshLayout.OnRefreshListener {
+
+    private HashMap<String, String> mExtends;
+    private FragmentTypeBinding mBinding;
+    private ArrayObjectAdapter mAdapter;
+    private ArrayObjectAdapter mLast;
+    private CustomScroller mScroller;
+    private SiteViewModel mViewModel;
+    private List<Filter> mFilters;
+    private boolean headerVisible;
+    private boolean filterVisible;
+
+    public static TypeFragment newInstance(String key, String typeId, Style style, HashMap<String, String> extend, boolean folder) {
+        Bundle args = new Bundle();
+        args.putString("key", key);
+        args.putString("typeId", typeId);
+        args.putBoolean("folder", folder);
+        args.putParcelable("style", style);
+        args.putSerializable("extend", extend);
+        TypeFragment fragment = new TypeFragment();
+        fragment.setArguments(args);
+        return fragment;
+    }
+
+    private String getKey() {
+        return getArguments().getString("key");
+    }
+
+    private String getTypeId() {
+        return getArguments().getString("typeId");
+    }
+
+    private boolean isFolder() {
+        return getArguments().getBoolean("folder");
+    }
+
+    private Style getStyle() {
+        return isFolder() ? Style.list() : getSite().getStyle(getArguments().getParcelable("style"));
+    }
+
+    private HashMap<String, String> getExtend() {
+        return (HashMap<String, String>) getArguments().getSerializable("extend");
+    }
+
+    private List<Filter> getFilter() {
+        return Cache.copy(getTypeId());
+    }
+
+    private Site getSite() {
+        return VodConfig.get().getSite(getKey());
+    }
+
+    private FolderFragment getParent() {
+        return ((FolderFragment) getParentFragment());
+    }
+
+    @Override
+    protected ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) {
+        return mBinding = FragmentTypeBinding.inflate(inflater, container, false);
+    }
+
+    @Override
+    protected void initView() {
+        mBinding.swipeLayout.setColorSchemeResources(R.color.accent);
+        mScroller = new CustomScroller(this);
+        mExtends = getExtend();
+        mFilters = getFilter();
+        setRecyclerView();
+        setViewModel();
+        setFilters();
+        getVideo();
+    }
+
+    @Override
+    protected void initEvent() {
+        mBinding.swipeLayout.setOnRefreshListener(this);
+        mBinding.recycler.addOnScrollListener(mScroller);
+    }
+
+    @SuppressLint("RestrictedApi")
+    private void setRecyclerView() {
+        CustomSelector selector = new CustomSelector();
+        selector.addPresenter(Vod.class, new VodPresenter(this, Style.list()));
+        selector.addPresenter(ListRow.class, new CustomRowPresenter(16), VodPresenter.class);
+        selector.addPresenter(ListRow.class, new CustomRowPresenter(8, FocusHighlight.ZOOM_FACTOR_NONE, HorizontalGridView.FOCUS_SCROLL_ALIGNED), FilterPresenter.class);
+        mBinding.recycler.setAdapter(new ItemBridgeAdapter(mAdapter = new ArrayObjectAdapter(selector)));
+        mBinding.recycler.setHeader(getActivity(), R.id.recycler);
+        mBinding.recycler.setVerticalSpacing(ResUtil.dp2px(16));
+    }
+
+    private void setViewModel() {
+        mViewModel = new ViewModelProvider(this).get(SiteViewModel.class);
+        mViewModel.getResult().observe(getViewLifecycleOwner(), this::setAdapter);
+        mViewModel.getAction().observe(getViewLifecycleOwner(), result -> Notify.show(result.getMsg()));
+    }
+
+    private void setFilters() {
+        for (Filter filter : mFilters) {
+            if (mExtends.containsKey(filter.getKey())) {
+                filter.setActivated(mExtends.get(filter.getKey()));
+            }
+        }
+    }
+
+    private void setClick(ArrayObjectAdapter adapter, String key, Value item) {
+        for (int i = 0; i < adapter.size(); i++) ((Value) adapter.get(i)).setActivated(item);
+        adapter.notifyArrayItemRangeChanged(0, adapter.size());
+        if (item.isActivated()) mExtends.put(key, item.getV());
+        else mExtends.remove(key);
+        onRefresh();
+    }
+
+    private void getVideo() {
+        mLast = null;
+        checkFilter();
+        mScroller.reset();
+        getVideo(getTypeId(), "1");
+    }
+
+    private void getVideo(String typeId, String page) {
+        mViewModel.categoryContent(getKey(), typeId, page, true, mExtends);
+    }
+
+    private void setAdapter(Result result) {
+        boolean first = mScroller.first();
+        boolean flag = mExtends.isEmpty();
+        int size = result.getList().size();
+        mBinding.progressLayout.showContent(first & flag, size);
+        mBinding.swipeLayout.setRefreshing(false);
+        mScroller.endLoading(result);
+        if (size > 0) addVideo(result);
+    }
+
+    private void addVideo(Result result) {
+        Style style = result.getStyle(getStyle());
+        if (style.isList()) mAdapter.addAll(mAdapter.size(), result.getList());
+        else addGrid(result.getList(), style);
+        checkMore();
+    }
+
+    private void checkMore() {
+        if (mScroller.isDisable() || mAdapter.size() >= 5) return;
+        getVideo(getTypeId(), String.valueOf(mScroller.addPage()));
+    }
+
+    private boolean checkLastSize(List<Vod> items, Style style) {
+        if (mLast == null || items.isEmpty()) return false;
+        int size = Product.getColumn(style) - mLast.size();
+        if (size == 0) return false;
+        size = Math.min(size, items.size());
+        mLast.addAll(mLast.size(), items.subList(0, size));
+        addGrid(items.subList(size, items.size()), style);
+        return true;
+    }
+
+    private void addGrid(List<Vod> items, Style style) {
+        if (checkLastSize(items, style)) return;
+        List<ListRow> rows = new ArrayList<>();
+        VodPresenter presenter = new VodPresenter(this, style);
+        for (List<Vod> part : Lists.partition(items, Product.getColumn(style))) {
+            mLast = new ArrayObjectAdapter(presenter);
+            mLast.addAll(0, part);
+            rows.add(new ListRow(mLast));
+        }
+        mAdapter.addAll(mAdapter.size(), rows);
+    }
+
+    private ListRow getRow(Filter filter) {
+        FilterPresenter presenter = new FilterPresenter(filter.getKey());
+        ArrayObjectAdapter adapter = new ArrayObjectAdapter(presenter);
+        presenter.setOnClickListener((key, item) -> setClick(adapter, key, item));
+        adapter.setItems(filter.getValue(), null);
+        return new ListRow(adapter);
+    }
+
+    private void showFilter() {
+        List<ListRow> rows = new ArrayList<>();
+        for (Filter filter : mFilters) rows.add(getRow(filter));
+        mBinding.recycler.postDelayed(() -> mBinding.recycler.scrollToPosition(0), 48);
+        mAdapter.addAll(0, rows);
+    }
+
+    private void hideFilter() {
+        mAdapter.removeItems(0, mFilters.size());
+    }
+
+    public void toggleFilter(boolean visible) {
+        if (mFilters.isEmpty()) return;
+        this.filterVisible = visible;
+        if (visible) showFilter();
+        else hideFilter();
+    }
+
+    private void checkFilter() {
+        int adapterSize = mAdapter.size();
+        int filterSize = filterVisible ? mFilters.size() : 0;
+        if (adapterSize > filterSize) mAdapter.removeItems(filterSize, mAdapter.size() - filterSize);
+        if (adapterSize == 0) mBinding.progressLayout.showProgress();
+        else mBinding.swipeLayout.setRefreshing(true);
+    }
+
+    public void onRefresh() {
+        getVideo();
+    }
+
+    @Override
+    public void onItemClick(Vod item) {
+        if (item.isAction()) {
+            mViewModel.action(getKey(), item.getAction());
+        } else if (item.isFolder()) {
+            getParent().openFolder(item.getId(), mExtends);
+            headerVisible = mBinding.recycler.isHeaderVisible();
+        } else {
+            if (getSite().isIndex()) CollectActivity.start(requireActivity(), item.getName());
+            else VideoActivity.start(requireActivity(), getKey(), item.getId(), item.getName(), item.getPic(), isFolder() ? item.getName() : null);
+        }
+    }
+
+    @Override
+    public boolean onLongClick(Vod item) {
+        if (item.isAction() || item.isFolder()) return false;
+        CollectActivity.start(requireActivity(), item.getName());
+        return true;
+    }
+
+    @Override
+    public void onLoadMore(String page) {
+        mScroller.setLoading(true);
+        getVideo(getTypeId(), page);
+    }
+
+    @Override
+    public void onHiddenChanged(boolean hidden) {
+        super.onHiddenChanged(hidden);
+        if (hidden) {
+            mBinding.recycler.showHeader();
+        } else {
+            if (headerVisible) mBinding.recycler.showHeader();
+            else mBinding.recycler.hideHeader();
+            mBinding.recycler.requestFocus();
+        }
+    }
+
+    @Override
+    public void setUserVisibleHint(boolean isVisibleToUser) {
+        super.setUserVisibleHint(isVisibleToUser);
+        if (mBinding != null) mBinding.recycler.moveToTop();
+    }
+}

+ 38 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/holder/VodListHolder.java

@@ -0,0 +1,38 @@
+package com.fongmi.android.tv.ui.holder;
+
+import androidx.annotation.NonNull;
+
+import com.bumptech.glide.Glide;
+import com.fongmi.android.tv.bean.Vod;
+import com.fongmi.android.tv.databinding.AdapterVodListBinding;
+import com.fongmi.android.tv.ui.base.BaseVodHolder;
+import com.fongmi.android.tv.ui.presenter.VodPresenter;
+import com.fongmi.android.tv.utils.ImgUtil;
+
+public class VodListHolder extends BaseVodHolder {
+
+    private final VodPresenter.OnClickListener listener;
+    private final AdapterVodListBinding binding;
+
+    public VodListHolder(@NonNull AdapterVodListBinding binding, VodPresenter.OnClickListener listener) {
+        super(binding.getRoot());
+        this.binding = binding;
+        this.listener = listener;
+    }
+
+    @Override
+    public void initView(Vod item) {
+        binding.name.setText(item.getName());
+        binding.remark.setText(item.getRemarks());
+        binding.name.setVisibility(item.getNameVisible());
+        binding.remark.setVisibility(item.getRemarkVisible());
+        binding.getRoot().setOnClickListener(v -> listener.onItemClick(item));
+        binding.getRoot().setOnLongClickListener(v -> listener.onLongClick(item));
+        ImgUtil.load(item.getName(), item.getPic(), binding.image, true);
+    }
+
+    @Override
+    public void unbind() {
+        Glide.with(binding.image).clear(binding.image);
+    }
+}

+ 42 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/holder/VodOvalHolder.java

@@ -0,0 +1,42 @@
+package com.fongmi.android.tv.ui.holder;
+
+import androidx.annotation.NonNull;
+
+import com.bumptech.glide.Glide;
+import com.fongmi.android.tv.bean.Vod;
+import com.fongmi.android.tv.databinding.AdapterVodOvalBinding;
+import com.fongmi.android.tv.ui.base.BaseVodHolder;
+import com.fongmi.android.tv.ui.presenter.VodPresenter;
+import com.fongmi.android.tv.utils.ImgUtil;
+
+public class VodOvalHolder extends BaseVodHolder {
+
+    private final VodPresenter.OnClickListener listener;
+    private final AdapterVodOvalBinding binding;
+
+    public VodOvalHolder(@NonNull AdapterVodOvalBinding binding, VodPresenter.OnClickListener listener) {
+        super(binding.getRoot());
+        this.binding = binding;
+        this.listener = listener;
+    }
+
+    public VodOvalHolder size(int[] size) {
+        binding.image.getLayoutParams().width = size[0];
+        binding.image.getLayoutParams().height = size[1];
+        return this;
+    }
+
+    @Override
+    public void initView(Vod item) {
+        binding.name.setText(item.getName());
+        binding.name.setVisibility(item.getNameVisible());
+        binding.getRoot().setOnClickListener(v -> listener.onItemClick(item));
+        binding.getRoot().setOnLongClickListener(v -> listener.onLongClick(item));
+        ImgUtil.load(item.getName(), item.getPic(), binding.image);
+    }
+
+    @Override
+    public void unbind() {
+        Glide.with(binding.image).clear(binding.image);
+    }
+}

+ 48 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/holder/VodRectHolder.java

@@ -0,0 +1,48 @@
+package com.fongmi.android.tv.ui.holder;
+
+import androidx.annotation.NonNull;
+
+import com.bumptech.glide.Glide;
+import com.fongmi.android.tv.bean.Vod;
+import com.fongmi.android.tv.databinding.AdapterVodRectBinding;
+import com.fongmi.android.tv.ui.base.BaseVodHolder;
+import com.fongmi.android.tv.ui.presenter.VodPresenter;
+import com.fongmi.android.tv.utils.ImgUtil;
+
+public class VodRectHolder extends BaseVodHolder {
+
+    private final VodPresenter.OnClickListener listener;
+    private final AdapterVodRectBinding binding;
+
+    public VodRectHolder(@NonNull AdapterVodRectBinding binding, VodPresenter.OnClickListener listener) {
+        super(binding.getRoot());
+        this.binding = binding;
+        this.listener = listener;
+    }
+
+    public VodRectHolder size(int[] size) {
+        binding.image.getLayoutParams().height = size[1];
+        binding.getRoot().getLayoutParams().width = size[0];
+        return this;
+    }
+
+    @Override
+    public void initView(Vod item) {
+        binding.name.setText(item.getName());
+        binding.year.setText(item.getYear());
+        binding.site.setText(item.getSiteName());
+        binding.remark.setText(item.getRemarks());
+        binding.site.setVisibility(item.getSiteVisible());
+        binding.year.setVisibility(item.getYearVisible());
+        binding.name.setVisibility(item.getNameVisible());
+        binding.remark.setVisibility(item.getRemarkVisible());
+        binding.getRoot().setOnClickListener(v -> listener.onItemClick(item));
+        binding.getRoot().setOnLongClickListener(v -> listener.onLongClick(item));
+        ImgUtil.load(item.getName(), item.getPic(), binding.image);
+    }
+
+    @Override
+    public void unbind() {
+        Glide.with(binding.image).clear(binding.image);
+    }
+}

+ 57 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/presenter/FilterPresenter.java

@@ -0,0 +1,57 @@
+package com.fongmi.android.tv.ui.presenter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.leanback.widget.Presenter;
+
+import com.fongmi.android.tv.bean.Value;
+import com.fongmi.android.tv.databinding.AdapterFilterBinding;
+
+public class FilterPresenter extends Presenter {
+
+    private OnClickListener listener;
+    private final String mKey;
+
+    public FilterPresenter(String key) {
+        mKey = key;
+    }
+
+    public interface OnClickListener {
+        void onItemClick(String key, Value item);
+    }
+
+    public void setOnClickListener(OnClickListener listener) {
+        this.listener = listener;
+    }
+
+    @NonNull
+    @Override
+    public Presenter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+        return new ViewHolder(AdapterFilterBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, Object object) {
+        Value item = (Value) object;
+        ViewHolder holder = (ViewHolder) viewHolder;
+        holder.binding.text.setText(item.getN());
+        holder.binding.text.setActivated(item.isActivated());
+        setOnClickListener(holder, view -> listener.onItemClick(mKey, item));
+    }
+
+    @Override
+    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+    }
+
+    public static class ViewHolder extends Presenter.ViewHolder {
+
+        private final AdapterFilterBinding binding;
+
+        public ViewHolder(@NonNull AdapterFilterBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 52 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/presenter/FuncPresenter.java

@@ -0,0 +1,52 @@
+package com.fongmi.android.tv.ui.presenter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.leanback.widget.Presenter;
+
+import com.fongmi.android.tv.bean.Func;
+import com.fongmi.android.tv.databinding.AdapterFuncBinding;
+
+public class FuncPresenter extends Presenter {
+
+    private final OnClickListener listener;
+
+    public FuncPresenter(OnClickListener listener) {
+        this.listener = listener;
+    }
+
+    public interface OnClickListener {
+        void onItemClick(Func item);
+    }
+
+    @NonNull
+    @Override
+    public Presenter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+        return new ViewHolder(AdapterFuncBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, Object object) {
+        Func item = (Func) object;
+        ViewHolder holder = (ViewHolder) viewHolder;
+        holder.binding.text.setText(item.getText());
+        holder.binding.icon.setImageResource(item.getDrawable());
+        setOnClickListener(holder, view -> listener.onItemClick(item));
+    }
+
+    @Override
+    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+    }
+
+    public static class ViewHolder extends Presenter.ViewHolder {
+
+        private final AdapterFuncBinding binding;
+
+        public ViewHolder(@NonNull AdapterFuncBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 39 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HeaderPresenter.java

@@ -0,0 +1,39 @@
+package com.fongmi.android.tv.ui.presenter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.leanback.widget.Presenter;
+
+import com.fongmi.android.tv.databinding.AdapterHeaderBinding;
+import com.fongmi.android.tv.utils.ResUtil;
+
+public class HeaderPresenter extends Presenter {
+
+    @NonNull
+    @Override
+    public Presenter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+        return new HeaderPresenter.ViewHolder(AdapterHeaderBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, Object object) {
+        HeaderPresenter.ViewHolder holder = (HeaderPresenter.ViewHolder) viewHolder;
+        holder.binding.text.setText(object instanceof String ? object.toString() : ResUtil.getString((int) object));
+    }
+
+    @Override
+    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+    }
+
+    public static class ViewHolder extends Presenter.ViewHolder {
+
+        private final AdapterHeaderBinding binding;
+
+        public ViewHolder(@NonNull AdapterHeaderBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 99 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java

@@ -0,0 +1,99 @@
+package com.fongmi.android.tv.ui.presenter;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.leanback.widget.Presenter;
+
+import com.bumptech.glide.Glide;
+import com.fongmi.android.tv.Product;
+import com.fongmi.android.tv.bean.History;
+import com.fongmi.android.tv.databinding.AdapterVodBinding;
+import com.fongmi.android.tv.utils.ImgUtil;
+import com.fongmi.android.tv.utils.ResUtil;
+
+public class HistoryPresenter extends Presenter {
+
+    private final OnClickListener listener;
+    private int width, height;
+    private boolean delete;
+
+    public HistoryPresenter(OnClickListener listener) {
+        this.listener = listener;
+        setLayoutSize();
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(History item);
+
+        void onItemDelete(History item);
+
+        boolean onLongClick();
+    }
+
+    private void setLayoutSize() {
+        int space = ResUtil.dp2px(48) + ResUtil.dp2px(16 * (Product.getColumn() - 1));
+        int base = ResUtil.getScreenWidth() - space;
+        width = base / Product.getColumn();
+        height = (int) (width / 0.75f);
+    }
+
+    public boolean isDelete() {
+        return delete;
+    }
+
+    public void setDelete(boolean delete) {
+        this.delete = delete;
+    }
+
+    private void setClickListener(View root, History item) {
+        root.setOnLongClickListener(view -> listener.onLongClick());
+        root.setOnClickListener(view -> {
+            if (isDelete()) listener.onItemDelete(item);
+            else listener.onItemClick(item);
+        });
+    }
+
+    @NonNull
+    @Override
+    public Presenter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+        ViewHolder holder = new ViewHolder(AdapterVodBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+        holder.binding.getRoot().getLayoutParams().width = width;
+        holder.binding.image.getLayoutParams().height = height;
+        return holder;
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, Object object) {
+        History item = (History) object;
+        boolean same = item.getVodName().equals(item.getVodRemarks());
+        ViewHolder holder = (ViewHolder) viewHolder;
+        setClickListener(holder.view, item);
+        holder.binding.name.setText(item.getVodName());
+        holder.binding.site.setText(item.getSiteName());
+        holder.binding.remark.setText(item.getVodRemarks());
+        holder.binding.site.setVisibility(item.getSiteVisible());
+        holder.binding.delete.setVisibility(!delete ? View.GONE : View.VISIBLE);
+        holder.binding.remark.setVisibility(delete || same ? View.GONE : View.VISIBLE);
+        ImgUtil.load(item.getVodName(), item.getVodPic(), holder.binding.image);
+    }
+
+    @Override
+    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+        ViewHolder holder = (ViewHolder) viewHolder;
+        Glide.with(holder.binding.image).clear(holder.binding.image);
+    }
+
+    public static class ViewHolder extends Presenter.ViewHolder {
+
+        private final AdapterVodBinding binding;
+
+        public ViewHolder(@NonNull AdapterVodBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}

+ 33 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/presenter/ProgressPresenter.java

@@ -0,0 +1,33 @@
+package com.fongmi.android.tv.ui.presenter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.leanback.widget.Presenter;
+
+import com.fongmi.android.tv.databinding.AdapterProgressBinding;
+
+public class ProgressPresenter extends Presenter {
+
+    @NonNull
+    @Override
+    public Presenter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+        return new ViewHolder(AdapterProgressBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, Object object) {
+    }
+
+    @Override
+    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+    }
+
+    public static class ViewHolder extends Presenter.ViewHolder {
+
+        public ViewHolder(@NonNull AdapterProgressBinding binding) {
+            super(binding.getRoot());
+        }
+    }
+}

+ 63 - 0
app/src/leanback/java/com/fongmi/android/tv/ui/presenter/VodPresenter.java

@@ -0,0 +1,63 @@
+package com.fongmi.android.tv.ui.presenter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.leanback.widget.Presenter;
+
+import com.fongmi.android.tv.Product;
+import com.fongmi.android.tv.bean.Style;
+import com.fongmi.android.tv.bean.Vod;
+import com.fongmi.android.tv.databinding.AdapterVodListBinding;
+import com.fongmi.android.tv.databinding.AdapterVodOvalBinding;
+import com.fongmi.android.tv.databinding.AdapterVodRectBinding;
+import com.fongmi.android.tv.ui.base.BaseVodHolder;
+import com.fongmi.android.tv.ui.base.ViewType;
+import com.fongmi.android.tv.ui.holder.VodListHolder;
+import com.fongmi.android.tv.ui.holder.VodOvalHolder;
+import com.fongmi.android.tv.ui.holder.VodRectHolder;
+
+public class VodPresenter extends Presenter {
+
+    private final OnClickListener listener;
+    private final Style style;
+    private final int[] size;
+
+    public VodPresenter(OnClickListener listener) {
+        this(listener, Style.rect());
+    }
+
+    public VodPresenter(OnClickListener listener, Style style) {
+        this.listener = listener;
+        this.style = style;
+        this.size = Product.getSpec(style);
+    }
+
+    public interface OnClickListener {
+
+        void onItemClick(Vod item);
+
+        boolean onLongClick(Vod item);
+    }
+
+    @NonNull
+    @Override
+    public Presenter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent) {
+        return switch (style.getViewType()) {
+            case ViewType.LIST -> new VodListHolder(AdapterVodListBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false), listener);
+            case ViewType.OVAL -> new VodOvalHolder(AdapterVodOvalBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false), listener).size(size);
+            default -> new VodRectHolder(AdapterVodRectBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false), listener).size(size);
+        };
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull Presenter.ViewHolder viewHolder, Object object) {
+        ((BaseVodHolder) viewHolder).initView((Vod) object);
+    }
+
+    @Override
+    public void onUnbindViewHolder(@NonNull Presenter.ViewHolder viewHolder) {
+        ((BaseVodHolder) viewHolder).unbind();
+    }
+}

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä