├── .github ├── ISSUE_TEMPLATE │ ├── bug-反馈.md │ └── 功能请求.md └── workflows │ └── release.yml ├── .gitignore ├── .idea ├── .gitignore ├── .name ├── appInsightsSettings.xml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── encodings.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── material_theme_project_new.xml ├── migrations.xml ├── misc.xml ├── other.xml ├── runConfigurations │ └── app.xml └── vcs.xml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── libs │ └── lib-decoder-ffmpeg-release.aar ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── top │ │ └── yogiczy │ │ └── mytv │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── top │ │ │ └── yogiczy │ │ │ └── mytv │ │ │ ├── AppGlobal.kt │ │ │ ├── BootReceiver.kt │ │ │ ├── MyTVApplication.kt │ │ │ ├── UnsafeTrustManager.kt │ │ │ ├── activities │ │ │ ├── LeanbackActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MobileActivity.kt │ │ │ └── PadActivity.kt │ │ │ ├── data │ │ │ ├── entities │ │ │ │ ├── Epg.kt │ │ │ │ ├── EpgList.kt │ │ │ │ ├── EpgProgramme.kt │ │ │ │ ├── EpgProgrammeCurrent.kt │ │ │ │ ├── EpgProgrammeList.kt │ │ │ │ ├── GitRelease.kt │ │ │ │ ├── Iptv.kt │ │ │ │ ├── IptvGroup.kt │ │ │ │ ├── IptvGroupList.kt │ │ │ │ └── IptvList.kt │ │ │ ├── repositories │ │ │ │ ├── FileCacheRepository.kt │ │ │ │ ├── epg │ │ │ │ │ ├── EpgRepository.kt │ │ │ │ │ └── fetcher │ │ │ │ │ │ ├── DefaultEpgFetcher.kt │ │ │ │ │ │ ├── EpgFetcher.kt │ │ │ │ │ │ ├── XmlEpgFetcher.kt │ │ │ │ │ │ └── XmlGzEpgFetcher.kt │ │ │ │ ├── git │ │ │ │ │ ├── GitRepository.kt │ │ │ │ │ └── parser │ │ │ │ │ │ ├── GitReleaseParser.kt │ │ │ │ │ │ ├── GiteeGitReleaseParser.kt │ │ │ │ │ │ └── GithubGitReleaseParser.kt │ │ │ │ └── iptv │ │ │ │ │ ├── IptvRepository.kt │ │ │ │ │ └── parser │ │ │ │ │ ├── DefaultIptvParser.kt │ │ │ │ │ ├── IptvParser.kt │ │ │ │ │ ├── M3uIptvParser.kt │ │ │ │ │ └── TvboxIptvParser.kt │ │ │ └── utils │ │ │ │ └── Constants.kt │ │ │ ├── ui │ │ │ ├── LeanbackApp.kt │ │ │ ├── screens │ │ │ │ └── leanback │ │ │ │ │ ├── classicpanel │ │ │ │ │ ├── ClassicPanelScreen.kt │ │ │ │ │ └── components │ │ │ │ │ │ ├── ClassicPanelEpgList.kt │ │ │ │ │ │ ├── ClassicPanelIptvGroupList.kt │ │ │ │ │ │ └── ClassicPanelIptvList.kt │ │ │ │ │ ├── components │ │ │ │ │ ├── Padding.kt │ │ │ │ │ ├── Qrcode.kt │ │ │ │ │ └── Visible.kt │ │ │ │ │ ├── main │ │ │ │ │ ├── MainScreen.kt │ │ │ │ │ ├── MainViewModel.kt │ │ │ │ │ └── components │ │ │ │ │ │ ├── MainContent.kt │ │ │ │ │ │ └── MainContentState.kt │ │ │ │ │ ├── monitor │ │ │ │ │ └── MonitorScreen.kt │ │ │ │ │ ├── panel │ │ │ │ │ ├── PanelAutoCloseState.kt │ │ │ │ │ ├── PanelChannelNoSelectScreen.kt │ │ │ │ │ ├── PanelDateTimeScreen.kt │ │ │ │ │ ├── PanelScreen.kt │ │ │ │ │ ├── PanelTempScreen.kt │ │ │ │ │ └── components │ │ │ │ │ │ ├── PanelChannelNo.kt │ │ │ │ │ │ ├── PanelDateTime.kt │ │ │ │ │ │ ├── PanelIptvEpg.kt │ │ │ │ │ │ ├── PanelIptvFavoriteList.kt │ │ │ │ │ │ ├── PanelIptvGroupList.kt │ │ │ │ │ │ ├── PanelIptvInfo.kt │ │ │ │ │ │ ├── PanelIptvItem.kt │ │ │ │ │ │ ├── PanelIptvList.kt │ │ │ │ │ │ └── PanelPlayerInfo.kt │ │ │ │ │ ├── quickpanel │ │ │ │ │ ├── QuickPanelScreen.kt │ │ │ │ │ └── components │ │ │ │ │ │ └── QuickPanelIptvChannelsDialog.kt │ │ │ │ │ ├── settings │ │ │ │ │ ├── SettingsCategories.kt │ │ │ │ │ ├── SettingsScreen.kt │ │ │ │ │ ├── SettingsViewModel.kt │ │ │ │ │ └── components │ │ │ │ │ │ ├── SettingsCategoryAbout.kt │ │ │ │ │ │ ├── SettingsCategoryApp.kt │ │ │ │ │ │ ├── SettingsCategoryContent.kt │ │ │ │ │ │ ├── SettingsCategoryDebug.kt │ │ │ │ │ │ ├── SettingsCategoryEpg.kt │ │ │ │ │ │ ├── SettingsCategoryFavorite.kt │ │ │ │ │ │ ├── SettingsCategoryHttp.kt │ │ │ │ │ │ ├── SettingsCategoryIptv.kt │ │ │ │ │ │ ├── SettingsCategoryList.kt │ │ │ │ │ │ ├── SettingsCategoryListItem.kt │ │ │ │ │ │ ├── SettingsCategoryLog.kt │ │ │ │ │ │ ├── SettingsCategoryMore.kt │ │ │ │ │ │ ├── SettingsCategoryUI.kt │ │ │ │ │ │ ├── SettingsCategoryUpdate.kt │ │ │ │ │ │ └── SettingsCategoryVideoPlayer.kt │ │ │ │ │ ├── toast │ │ │ │ │ ├── ToastScreen.kt │ │ │ │ │ └── ToastState.kt │ │ │ │ │ ├── update │ │ │ │ │ ├── UpdateScreen.kt │ │ │ │ │ ├── UpdateViewModel.kt │ │ │ │ │ └── components │ │ │ │ │ │ └── UpdateDialog.kt │ │ │ │ │ └── video │ │ │ │ │ ├── VideoPlayerErrorScreen.kt │ │ │ │ │ ├── VideoPlayerState.kt │ │ │ │ │ ├── VideoScreen.kt │ │ │ │ │ ├── components │ │ │ │ │ └── VideoPlayerMetadata.kt │ │ │ │ │ └── player │ │ │ │ │ ├── Media3VideoPlayer.kt │ │ │ │ │ └── VideoPlayer.kt │ │ │ ├── theme │ │ │ │ ├── LeanbackTheme.kt │ │ │ │ ├── MobileTheme.kt │ │ │ │ └── PadTheme.kt │ │ │ └── utils │ │ │ │ ├── HttpServer.kt │ │ │ │ ├── ModifierUtils.kt │ │ │ │ └── SP.kt │ │ │ └── utils │ │ │ ├── ApkInstaller.kt │ │ │ ├── Downloader.kt │ │ │ ├── ExtensionUtils.kt │ │ │ └── Logger.kt │ └── res │ │ ├── drawable-xhdpi │ │ └── tv_banner.png │ │ ├── mipmap-anydpi-v26 │ │ └── ic_launcher.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── raw │ │ ├── index.html │ │ ├── index_css.css │ │ └── index_js.js │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ ├── file_paths.xml │ │ └── network_security_config.xml │ └── test │ └── java │ └── top │ └── yogiczy │ └── mytv │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshots ├── Screenshot_panel.png ├── Screenshot_settings.png ├── Screenshot_temp_panel.png └── mm_reward_qrcode.png └── settings.gradle.kts /.github/ISSUE_TEMPLATE/bug-反馈.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: bug-反馈 3 | about: 描述你所遇到的bug 4 | title: 问题反馈 5 | labels: bug 6 | assignees: yaoxieyoulei 7 | 8 | --- 9 | 10 | ### 问题描述 11 | 请提供一个清晰而简明的问题描述。 12 | 13 | ### 复现步骤 14 | 请提供复现该问题所需的具体步骤。 15 | 16 | ### 预期行为 17 | 请描述你期望的正确行为或结果。 18 | 19 | ### 系统信息 20 | 请提供关于您的环境的详细信息,包括操作系统、浏览器版本等。 21 | 22 | ### 相关截图或日志 23 | 如果有的话,请提供相关的截图、错误日志或其他有助于解决问题的信息。 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/功能请求.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 功能请求 3 | about: 对于功能的一些建议 4 | title: '' 5 | labels: enhancement 6 | assignees: yaoxieyoulei 7 | 8 | --- 9 | 10 | ### 功能描述 11 | 请提供对所请求功能的清晰描述。 12 | 13 | ### 目标 14 | 请描述你希望通过这个功能实现的目标。 15 | 16 | ### 解决方案 17 | 如果你有任何关于如何实现这个功能的想法或建议,请在这里提供。 18 | 19 | ### 其他 20 | 请提供已实现该功能或类似功能的应用 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: 代码迁出 14 | uses: actions/checkout@v4 15 | 16 | - name: 构建Java环境 17 | uses: actions/setup-java@v4 18 | with: 19 | distribution: "zulu" 20 | java-version: "17" 21 | 22 | - name: 解码生成 jks 23 | run: echo $KEYSTORE_BASE64 | base64 -di > app/keystore.jks 24 | env: 25 | KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} 26 | 27 | - name: 生成apk 28 | run: chmod +x ./gradlew && ./gradlew assembleRelease 29 | 30 | env: 31 | KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} 32 | KEY_ALIAS: ${{ secrets.KEY_ALIAS }} 33 | KEY_PASSWORD: ${{ secrets.KEY_PASSWORD}} 34 | 35 | - name: 获取版本号 36 | id: version 37 | run: echo "version=${GITHUB_REF#refs/tags/v}" >>$GITHUB_OUTPUT 38 | 39 | - name: 重命名应用 40 | run: | 41 | for file in app/build/outputs/apk/release/app-*.apk; do 42 | if [[ $file =~ app-(.?*)release.apk ]]; then 43 | new_file_name="app/build/outputs/apk/release/mytv-android-${BASH_REMATCH[1]}${{ steps.version.outputs.version }}.apk" 44 | mv "$file" "$new_file_name" 45 | fi 46 | done 47 | 48 | - name: Prepare artifacts 49 | run: | 50 | mkdir artifacts 51 | cp app/build/outputs/apk/release/*.apk artifacts/ 52 | 53 | - name: Upload Release 54 | uses: ncipollo/release-action@v1 55 | with: 56 | name: v${{ steps.version.outputs.version }} 57 | token: ${{ secrets.GIT_TOKEN }} 58 | omitBodyDuringUpdate: true 59 | omitNameDuringUpdate: true 60 | omitPrereleaseDuringUpdate: true 61 | allowUpdates: true 62 | artifacts: artifacts/* 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | /.idea/deploymentTargetDropDown.xml 11 | /.idea/git_toolbox_prj.xml 12 | /.idea/deploymentTargetSelector.xml 13 | /.idea/GrepConsole.xml 14 | /.idea/material_theme_project_new.xml 15 | /.idea/other.xml 16 | .DS_Store 17 | /build 18 | /captures 19 | .externalNativeBuild 20 | .cxx 21 | local.properties 22 | key.properties -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # 默认忽略的文件 2 | /shelf/ 3 | /workspace.xml 4 | ./deploymentTargetSelector.xml 5 | /git_toolbox_blame.xml -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | My TV -------------------------------------------------------------------------------- /.idea/appInsightsSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | 26 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 122 | 123 | 130 | 131 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 70 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/material_theme_project_new.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations/app.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 68 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | ## [1.4.4] - 2024-08-15 4 | 5 | ### 优化 6 | 7 | - 设置网页依赖本地化 8 | 9 | ## [1.4.3] - 2024-08-01 10 | 11 | ### 新增 12 | 13 | - 新增超时换源、断线重连 14 | - 支持rtsp 15 | - 新增全局画面比例 16 | 17 | ### 优化 18 | 19 | - 默认禁用画中画 20 | 21 | ### 修复 22 | 23 | - 尝试修复手机端设置界面直接退出应用 24 | 25 | ## [1.4.2] - 2024-06-29 26 | 27 | ### 新增 28 | 29 | - 新增自定义浏览器UA 30 | - 新增后台时画中画 31 | 32 | ### 优化 33 | 34 | - 优化节目单、收藏显示逻辑 35 | - 优化经典选台界面UI 36 | - 优化设置界面 37 | - 优化tvbox直播源解析 38 | - 经典选台界面支持多天节目单 39 | - 优化toast样式 40 | 41 | ### 修复 42 | 43 | - 修复手机端无法点击更新按钮 44 | 45 | ## [1.4.1] - 2024-06-19 46 | 47 | ### 优化 48 | 49 | - 优化节目单显示逻辑 50 | 51 | ### 修复 52 | 53 | - 修复设置页面地址错误 54 | 55 | ## [1.4.0] - 2024-06-11 56 | 57 | ### 新增 58 | 59 | - 超时自动关闭选台界面 60 | - 新增多线路快捷切换 61 | - 设置网页添加日志历史 62 | - 不支持格式音频采用ffmpeg解码 63 | 64 | ### 优化 65 | 66 | - 优化界面逻辑 67 | - 优化界面性能 68 | - 加载界面可以打开设置界面 69 | - 优化多线路选择逻辑 70 | 71 | ### 修复 72 | 73 | - 修复未知直播源格式导致闪退 74 | 75 | ## [1.3.1] - 2024-05-08 76 | 77 | ### 新增 78 | 79 | - 新增频道ipv4、ipv6标识 80 | - 新增更新强提醒 81 | - 新增经典选台界面 82 | - 新增界面缩放 83 | - 新增时间显示 84 | 85 | ### 优化 86 | 87 | - 显示频道分组数量 88 | - 左右切换节目单 89 | - 优化设置界面 90 | 91 | ### 修复 92 | 93 | - 修复部分链接播放失败 94 | 95 | ## [1.3.0] - 2024-04-25 96 | 97 | ### 新增 98 | 99 | - 多直播源(历史直播源) 100 | - 多线路 101 | - 多节目单(历史节目单) 102 | - 当天节目单 103 | - 频道收藏 104 | - 节目进度 105 | - 播放器信息 106 | 107 | ### 优化 108 | 109 | - 防止更新提示太频繁 110 | - 修复打开选台界面后,未能正确聚焦到当前频道 111 | - 跟换epg默认地址 112 | - 设置页面新增上传apk 113 | 114 | ## [1.2.0] - 2024-04-18 115 | 116 | ### 新增 117 | 118 | - 应用自定义设置(访问以下网址:`http://<设备IP>:10481`) 119 | - 自定义直播源、节目单、缓存时间等 120 | 121 | ### 修复 122 | 123 | - 修复epg获取失败导致出现加载失败界面 124 | 125 | ### 删除 126 | 127 | - 删除设置界面部分设置项,请前往更多设置(位于设置界面最后面) 128 | 129 | ## [1.1.0] - 2024-04-16 130 | 131 | ### 新增 132 | 133 | - 新增自动更新 134 | - 新增自定义源 135 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-PRESENT yaoxieyoulei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

我的电视

3 |
4 | 5 | ![GitHub repo size](https://img.shields.io/github/repo-size/yaoxieyoulei/mytv-android) 6 | ![GitHub Repo stars](https://img.shields.io/github/stars/yaoxieyoulei/mytv-android) 7 | ![GitHub all releases](https://img.shields.io/github/downloads/yaoxieyoulei/mytv-android/total) 8 | 9 |
10 |

使用Android原生开发的视频播放软件

11 |
12 | 13 | ## 使用 14 | 15 | ### 操作方式 16 | 17 | > 遥控器操作方式与主流视频播放软件类似; 18 | 19 | - 频道切换:使用上下方向键,或者数字键切换频道;屏幕上下滑动; 20 | - 频道选择:OK键;单击屏幕; 21 | - 设置页面:按下菜单、帮助键,长按OK键;双击、长按屏幕; 22 | 23 | ### 触摸键位对应 24 | 25 | - 方向键:屏幕上下左右滑动 26 | - OK键:点击屏幕 27 | - 长按OK键:长按屏幕 28 | - 菜单、帮助键:双击屏幕 29 | 30 | ### 自定义设置 31 | 32 | - 访问以下网址:`http://<设备IP>:10481` 33 | - 打开应用设置界面,移到最后一项 34 | - 支持自定义订阅源、自定义节目单、缓存时间等等 35 | - 须知:网页中引用了`jsdelivr`的cdn,请确保能够正常访问 36 | 37 | ### 自定义订阅源 38 | 39 | - 设置入口:自定义设置网址 40 | - 格式支持:m3u格式、tvbox格式 41 | 42 | ### 多订阅源 43 | 44 | - 设置入口:打开应用设置界面,选中`自定义订阅源`项,点击后将弹出历史订阅源列表 45 | - 历史订阅源列表:短按可切换当前订阅源(需重启),长按将清除历史记录;该功能类似于`多仓`,主要用于简化订阅源切换流程 46 | - 须知: 47 | 1. 当订阅源数据获取成功时,会将该订阅源保存到历史订阅源列表中 48 | 2. 当订阅源数据获取失败时,会将该订阅源移出历史订阅源列表 49 | 50 | ### 多线路 51 | 52 | - 功能描述:同一频道拥有多个播放地址,相关标识位于频道名称后面 53 | - 切换线路:左右方向键;屏幕左右滑动 54 | - 自动切换:当当前线路播放失败后,将自动播放下一个线路,直至最后 55 | - 须知: 56 | 1. 当某一线路播放成功后,会将该线路的`域名`保存到`可播放域名列表`中 57 | 2. 当某一线路播放失败后,会将该线路的`域名`移出`可播放域名列表` 58 | 3. 当播放某一频道时,将优先选择匹配`可播放域名列表`的线路 59 | 60 | ### 自定义节目单 61 | 62 | - 设置入口:自定义设置网址 63 | - 格式支持:.xml、.xml.gz格式 64 | 65 | ### 多节目单 66 | 67 | - 设置入口:打开应用设置界面,选中`自定义节目单`项,点击后将弹出历史节目单列表 68 | - 具体功能请参照`多订阅源` 69 | 70 | ### 当天节目单 71 | 72 | - 功能入口:打开应用选台界面,选中某一频道,按下菜单、帮助键、双击屏幕,将打开当天节目单 73 | - 须知:由于该应用不支持回放功能,所以更早的节目单没必要展示 74 | 75 | ### 频道收藏 76 | 77 | - 功能入口:打开应用选台界面,选中某一频道,长按OK键、长按屏幕,将收藏/取消收藏该频道 78 | - 切换显示收藏列表:首先移动到频道列表顶部,然后再次按下方向键上,将切换显示收藏列表;手机长按频道信息切换 79 | 80 | ## 下载 81 | 82 | 可以通过右侧release进行下载或拉取代码到本地进行编译 83 | 84 | ## 说明 85 | 86 | - 主要解决 [my_tv](https://github.com/yaoxieyoulei/my_tv)(flutter)在低端设备上播放(4k)视频卡顿掉帧 87 | - 仅支持Android5及以上 88 | - 网络环境必须支持IPV6(默认订阅源) 89 | - 只在自家电视上测过,其他电视稳定性未知 90 | 91 | ## 功能 92 | 93 | - [x] 换台反转 94 | - [x] 数字选台 95 | - [x] 节目单 96 | - [x] 开机自启 97 | - [x] 自动更新 98 | - [x] 多订阅源 99 | - [x] 多线路 100 | - [x] 自定义订阅源 101 | - [x] 多节目单 102 | - [x] 自定义节目单 103 | - [x] 频道收藏 104 | - [x] 应用自定义设置 105 | 106 | ## 更新日志 107 | 108 | [更新日志](./CHANGELOG.md) 109 | 110 | ## 声明 111 | 112 | 此项目(我的电视)是个人为了兴趣而开发, 仅用于学习和测试。 所用API皆从官方网站收集, 不提供任何破解内容。 113 | 114 | ## 技术交流 115 | 116 | Telegram: https://t.me/mytv_android 117 | 118 | ## 赞赏 119 | 120 | 121 | 122 | ## 致谢 123 | 124 | - [my-tv](https://github.com/lizongying/my-tv) 125 | - [参考设计稿](https://github.com/lizongying/my-tv/issues/594) 126 | - [IPV6直播源](https://github.com/zhumeng11/IPTV) 127 | - [live](https://github.com/fanmingming/live) 128 | - 等等 129 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | keystore.jks 3 | /release 4 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.io.FileInputStream 2 | import java.util.Properties 3 | 4 | plugins { 5 | alias(libs.plugins.android.application) 6 | alias(libs.plugins.jetbrains.kotlin.android) 7 | alias(libs.plugins.compose.compiler) 8 | alias(libs.plugins.kotlin.serialization) 9 | } 10 | 11 | val keystorePropertiesFile = rootProject.file("key.properties") 12 | val keystoreProperties = Properties() 13 | if (keystorePropertiesFile.exists()) { 14 | keystoreProperties.load(FileInputStream(keystorePropertiesFile)) 15 | } 16 | 17 | android { 18 | namespace = "top.yogiczy.mytv" 19 | compileSdk = 34 20 | 21 | defaultConfig { 22 | applicationId = "top.yogiczy.mytv" 23 | minSdk = 21 24 | targetSdk = 34 25 | versionCode = 1 26 | versionName = "1.4.4" 27 | 28 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 29 | vectorDrawables { 30 | useSupportLibrary = true 31 | } 32 | 33 | ndk { 34 | abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86_64")) 35 | } 36 | } 37 | 38 | buildTypes { 39 | release { 40 | isMinifyEnabled = true 41 | isShrinkResources = true 42 | proguardFiles( 43 | getDefaultProguardFile("proguard-android-optimize.txt"), 44 | "proguard-rules.pro" 45 | ) 46 | } 47 | } 48 | compileOptions { 49 | sourceCompatibility = JavaVersion.VERSION_1_8 50 | targetCompatibility = JavaVersion.VERSION_1_8 51 | } 52 | kotlinOptions { 53 | jvmTarget = "1.8" 54 | } 55 | buildFeatures { 56 | compose = true 57 | } 58 | packaging { 59 | resources { 60 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 61 | } 62 | } 63 | signingConfigs { 64 | create("release") { 65 | storeFile = 66 | file(System.getenv("KEYSTORE") ?: keystoreProperties["storeFile"] ?: "keystore.jks") 67 | storePassword = System.getenv("KEYSTORE_PASSWORD") 68 | ?: keystoreProperties.getProperty("storePassword") 69 | keyAlias = System.getenv("KEY_ALIAS") ?: keystoreProperties.getProperty("keyAlias") 70 | keyPassword = 71 | System.getenv("KEY_PASSWORD") ?: keystoreProperties.getProperty("keyPassword") 72 | } 73 | } 74 | buildTypes { 75 | getByName("release") { 76 | signingConfig = signingConfigs.getByName("release") 77 | } 78 | } 79 | } 80 | 81 | dependencies { 82 | 83 | implementation(libs.androidx.core.ktx) 84 | implementation(libs.androidx.lifecycle.runtime.ktx) 85 | implementation(libs.androidx.lifecycle.viewmodel.compose) 86 | implementation(libs.androidx.activity.compose) 87 | implementation(platform(libs.androidx.compose.bom)) 88 | implementation(libs.androidx.ui) 89 | implementation(libs.androidx.ui.graphics) 90 | implementation(libs.androidx.ui.tooling.preview) 91 | implementation(libs.androidx.material3) 92 | implementation(libs.kotlinx.collections.immutable) 93 | implementation(libs.androidx.material.icons.extended) 94 | 95 | // TV Compose 96 | implementation(libs.androidx.tv.foundation) 97 | implementation(libs.androidx.tv.material) 98 | 99 | // 播放器 100 | implementation(libs.androidx.media3.exoplayer) 101 | implementation(libs.androidx.media3.exoplayer.hls) 102 | implementation(libs.androidx.media3.exoplayer.rtsp) 103 | 104 | // 序列化 105 | implementation(libs.kotlinx.serialization) 106 | 107 | // 网络请求 108 | implementation(libs.okhttp) 109 | implementation(libs.androidasync) 110 | 111 | // 二维码 112 | implementation(libs.qrose) 113 | 114 | implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) 115 | 116 | testImplementation(libs.junit) 117 | androidTestImplementation(libs.androidx.junit) 118 | androidTestImplementation(libs.androidx.espresso.core) 119 | androidTestImplementation(platform(libs.androidx.compose.bom)) 120 | androidTestImplementation(libs.androidx.ui.test.junit4) 121 | debugImplementation(libs.androidx.ui.tooling) 122 | debugImplementation(libs.androidx.ui.test.manifest) 123 | } -------------------------------------------------------------------------------- /app/libs/lib-decoder-ffmpeg-release.aar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/libs/lib-decoder-ffmpeg-release.aar -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/top/yogiczy/mytv/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("top.yogiczy.mytv", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 30 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 60 | 61 | 62 | 63 | 64 | 65 | 69 | 70 | 71 | 72 | 73 | 74 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 90 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/AppGlobal.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv 2 | 3 | import java.io.File 4 | 5 | /** 6 | * 应用全局变量 7 | */ 8 | object AppGlobal { 9 | /** 10 | * 缓存目录 11 | */ 12 | lateinit var cacheDir: File 13 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/BootReceiver.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.SharedPreferences 7 | import top.yogiczy.mytv.activities.LeanbackActivity 8 | import top.yogiczy.mytv.ui.utils.SP 9 | 10 | /** 11 | * 开机自启动监听 12 | */ 13 | class BootReceiver : BroadcastReceiver() { 14 | override fun onReceive(context: Context, intent: Intent) { 15 | if (Intent.ACTION_BOOT_COMPLETED == intent.action) { 16 | val sp: SharedPreferences = SP.getInstance(context) 17 | val bootLaunch = sp.getBoolean(SP.KEY.APP_BOOT_LAUNCH.name, false) 18 | 19 | if (bootLaunch) { 20 | context.startActivity(Intent(context, LeanbackActivity::class.java).apply { 21 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 22 | }) 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/MyTVApplication.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv 2 | 3 | import android.app.Application 4 | import top.yogiczy.mytv.ui.utils.SP 5 | 6 | class MyTVApplication : Application() { 7 | override fun onCreate() { 8 | super.onCreate() 9 | 10 | UnsafeTrustManager.enableUnsafeTrustManager() 11 | AppGlobal.cacheDir = applicationContext.cacheDir 12 | SP.init(applicationContext) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/UnsafeTrustManager.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv 2 | 3 | import android.annotation.SuppressLint 4 | import java.security.KeyManagementException 5 | import java.security.NoSuchAlgorithmException 6 | import java.security.SecureRandom 7 | import java.security.cert.X509Certificate 8 | import javax.net.ssl.HttpsURLConnection 9 | import javax.net.ssl.SSLContext 10 | import javax.net.ssl.TrustManager 11 | import javax.net.ssl.X509TrustManager 12 | 13 | // 防止部分直播源链接证书不被信任 14 | @SuppressLint("CustomX509TrustManager") 15 | class UnsafeTrustManager : X509TrustManager { 16 | @SuppressLint("TrustAllX509TrustManager") 17 | override fun checkClientTrusted(chain: Array, authType: String) { 18 | // Do nothing and trust all certificates 19 | } 20 | 21 | @SuppressLint("TrustAllX509TrustManager") 22 | override fun checkServerTrusted(chain: Array, authType: String) { 23 | // Do nothing and trust all certificates 24 | } 25 | 26 | override fun getAcceptedIssuers(): Array { 27 | return emptyArray() 28 | } 29 | 30 | companion object { 31 | fun enableUnsafeTrustManager() { 32 | try { 33 | val trustAllCerts = arrayOf(UnsafeTrustManager()) 34 | val sslContext = SSLContext.getInstance("TLS") 35 | sslContext.init(null, trustAllCerts, SecureRandom()) 36 | HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) 37 | HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true } 38 | } catch (e: NoSuchAlgorithmException) { 39 | e.printStackTrace() 40 | } catch (e: KeyManagementException) { 41 | e.printStackTrace() 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/activities/LeanbackActivity.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.activities 2 | 3 | import android.app.PictureInPictureParams 4 | import android.os.Build 5 | import android.os.Bundle 6 | import android.util.Rational 7 | import android.view.WindowManager 8 | import androidx.activity.ComponentActivity 9 | import androidx.activity.compose.setContent 10 | import androidx.activity.enableEdgeToEdge 11 | import androidx.compose.foundation.background 12 | import androidx.compose.foundation.layout.Box 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.ui.Modifier 16 | import androidx.core.view.WindowCompat 17 | import androidx.core.view.WindowInsetsCompat 18 | import androidx.core.view.WindowInsetsControllerCompat 19 | import top.yogiczy.mytv.ui.LeanbackApp 20 | import top.yogiczy.mytv.ui.screens.leanback.toast.LeanbackToastState 21 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 22 | import top.yogiczy.mytv.ui.utils.HttpServer 23 | import top.yogiczy.mytv.ui.utils.SP 24 | import kotlin.system.exitProcess 25 | 26 | class LeanbackActivity : ComponentActivity() { 27 | override fun onUserLeaveHint() { 28 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return 29 | if (!SP.uiPipMode) return 30 | 31 | enterPictureInPictureMode( 32 | PictureInPictureParams.Builder() 33 | .setAspectRatio(Rational(16, 9)) 34 | .build() 35 | ) 36 | super.onUserLeaveHint() 37 | } 38 | 39 | override fun onCreate(savedInstanceState: Bundle?) { 40 | super.onCreate(savedInstanceState) 41 | enableEdgeToEdge() 42 | setContent { 43 | // 隐藏状态栏、导航栏 44 | WindowCompat.setDecorFitsSystemWindows(window, false) 45 | WindowCompat.getInsetsController(window, window.decorView).let { insetsController -> 46 | insetsController.hide(WindowInsetsCompat.Type.statusBars()) 47 | insetsController.hide(WindowInsetsCompat.Type.navigationBars()) 48 | insetsController.systemBarsBehavior = 49 | WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE 50 | } 51 | 52 | // 屏幕常亮 53 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 54 | 55 | LeanbackTheme { 56 | Box( 57 | modifier = Modifier 58 | .fillMaxSize() 59 | .background(MaterialTheme.colorScheme.background), 60 | ) { 61 | LeanbackApp( 62 | onBackPressed = { 63 | finish() 64 | exitProcess(0) 65 | }, 66 | ) 67 | } 68 | } 69 | } 70 | 71 | HttpServer.start(applicationContext, showToast = { 72 | LeanbackToastState.I.showToast(it, id = "httpServer") 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/activities/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.activities 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import top.yogiczy.mytv.ui.utils.SP 7 | 8 | class MainActivity : ComponentActivity() { 9 | override fun onCreate(savedInstanceState: Bundle?) { 10 | super.onCreate(savedInstanceState) 11 | 12 | val activityClass = when (SP.appDeviceDisplayType) { 13 | SP.AppDeviceDisplayType.LEANBACK -> LeanbackActivity::class.java 14 | SP.AppDeviceDisplayType.MOBILE -> MobileActivity::class.java 15 | SP.AppDeviceDisplayType.PAD -> PadActivity::class.java 16 | } 17 | 18 | // TODO 切换时变化生硬 19 | startActivity(Intent(this, activityClass).apply { 20 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 21 | }) 22 | 23 | finish() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/activities/MobileActivity.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.activities 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.Scaffold 11 | import androidx.compose.material3.Text 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import top.yogiczy.mytv.ui.theme.MobileTheme 15 | 16 | class MobileActivity : ComponentActivity() { 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | enableEdgeToEdge() 20 | setContent { 21 | MobileTheme { 22 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> 23 | Box(modifier = Modifier.fillMaxSize()) { 24 | Text( 25 | text = "MobileActivity", 26 | modifier = Modifier 27 | .padding(innerPadding) 28 | .align(Alignment.Center), 29 | ) 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/activities/PadActivity.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.activities 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.Scaffold 11 | import androidx.compose.material3.Text 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import top.yogiczy.mytv.ui.theme.PadTheme 15 | 16 | class PadActivity : ComponentActivity() { 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | enableEdgeToEdge() 20 | setContent { 21 | PadTheme { 22 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> 23 | Box(modifier = Modifier.fillMaxSize()) { 24 | Text( 25 | text = "PadActivity", 26 | modifier = Modifier 27 | .padding(innerPadding) 28 | .align(Alignment.Center), 29 | ) 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/entities/Epg.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.entities 2 | 3 | import kotlinx.serialization.Serializable 4 | import top.yogiczy.mytv.data.entities.EpgProgramme.Companion.isLive 5 | 6 | /** 7 | * 频道节目单 8 | */ 9 | @Serializable 10 | data class Epg( 11 | /** 12 | * 频道名称 13 | */ 14 | val channel: String = "", 15 | 16 | /** 17 | * 节目列表 18 | */ 19 | val programmes: EpgProgrammeList = EpgProgrammeList(), 20 | ) { 21 | companion object { 22 | /** 23 | * 当前节目/下一个节目 24 | */ 25 | fun Epg.currentProgrammes(): EpgProgrammeCurrent? { 26 | val currentProgramme = programmes.firstOrNull { it.isLive() } ?: return null 27 | 28 | return EpgProgrammeCurrent( 29 | now = currentProgramme, 30 | next = programmes.indexOf(currentProgramme).let { index -> 31 | if (index + 1 < programmes.size) programmes[index + 1] 32 | else null 33 | }, 34 | ) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/entities/EpgList.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.entities 2 | 3 | import androidx.compose.runtime.Immutable 4 | import top.yogiczy.mytv.data.entities.Epg.Companion.currentProgrammes 5 | 6 | @Immutable 7 | data class EpgList( 8 | val value: List = emptyList(), 9 | ) : List by value { 10 | companion object { 11 | /** 12 | * 当前节目/下一个节目 13 | */ 14 | fun EpgList.currentProgrammes(iptv: Iptv): EpgProgrammeCurrent? { 15 | return firstOrNull { it.channel == iptv.channelName }?.currentProgrammes() 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/entities/EpgProgramme.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.entities 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | /** 6 | * 频道节目 7 | */ 8 | @Serializable 9 | data class EpgProgramme( 10 | /** 11 | * 开始时间(时间戳) 12 | */ 13 | val startAt: Long = 0, 14 | 15 | /** 16 | * 结束时间(时间戳) 17 | */ 18 | val endAt: Long = 0, 19 | 20 | /** 21 | * 节目名称 22 | */ 23 | val title: String = "", 24 | ) { 25 | companion object { 26 | /** 27 | * 是否正在直播 28 | */ 29 | fun EpgProgramme.isLive() = System.currentTimeMillis() in startAt.. = emptyList(), 10 | ) : List by value 11 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/entities/GitRelease.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.entities 2 | 3 | /** 4 | * git版本 5 | */ 6 | data class GitRelease( 7 | val version: String = "0.0.0", 8 | val downloadUrl: String = "", 9 | val description: String = "", 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/entities/Iptv.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.entities 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | /** 6 | * 直播源 7 | */ 8 | @Immutable 9 | data class Iptv( 10 | /** 11 | * 直播源名称 12 | */ 13 | val name: String = "", 14 | 15 | /** 16 | * 频道名称,用于查询节目单 17 | */ 18 | val channelName: String = "", 19 | 20 | /** 21 | * 播放地址 22 | */ 23 | val urlList: List = emptyList(), 24 | ) { 25 | companion object { 26 | val EXAMPLE = Iptv( 27 | name = "CCTV-1", 28 | channelName = "cctv1", 29 | urlList = listOf( 30 | "http://dbiptv.sn.chinamobile.com/PLTV/88888890/224/3221226231/index.m3u8", 31 | "http://[2409:8087:5e01:34::20]:6610/ZTE_CMS/00000001000000060000000000000131/index.m3u8?IAS", 32 | ), 33 | ) 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/entities/IptvGroup.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.entities 2 | 3 | /** 4 | * 直播源分组 5 | */ 6 | data class IptvGroup( 7 | /** 8 | * 分组名称 9 | */ 10 | val name: String = "", 11 | 12 | /** 13 | * 直播源列表 14 | */ 15 | val iptvList: IptvList = IptvList(), 16 | ) -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/entities/IptvGroupList.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.entities 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | /** 6 | * 直播源分组列表 7 | */ 8 | @Immutable 9 | data class IptvGroupList( 10 | val value: List = emptyList(), 11 | ) : List by value { 12 | companion object { 13 | val EXAMPLE = IptvGroupList(List(5) { groupIdx -> 14 | IptvGroup( 15 | name = "频道分组${groupIdx + 1}", 16 | iptvList = IptvList( 17 | List(10) { idx -> 18 | Iptv( 19 | name = "频道${groupIdx + 1}-${idx + 1}", 20 | channelName = "频道${groupIdx + 1}-${idx + 1}", 21 | urlList = emptyList(), 22 | ) 23 | }, 24 | ) 25 | ) 26 | }) 27 | 28 | fun IptvGroupList.iptvGroupIdx(iptv: Iptv) = 29 | this.indexOfFirst { group -> group.iptvList.any { it == iptv } } 30 | 31 | fun IptvGroupList.iptvIdx(iptv: Iptv) = 32 | this.flatMap { it.iptvList }.indexOfFirst { it == iptv } 33 | 34 | val IptvGroupList.iptvList: List 35 | get() = this.flatMap { it.iptvList } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/entities/IptvList.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.entities 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | /** 6 | * 直播源列表 7 | */ 8 | @Immutable 9 | data class IptvList( 10 | val value: List = emptyList(), 11 | ) : List by value { 12 | companion object { 13 | val EXAMPLE = IptvList(List(10) { i -> Iptv.EXAMPLE.copy(name = "CCTV-$i") }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/FileCacheRepository.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import top.yogiczy.mytv.AppGlobal 6 | import java.io.File 7 | 8 | /** 9 | * 用于将数据缓存至本地 10 | */ 11 | abstract class FileCacheRepository( 12 | private val fileName: String, 13 | ) { 14 | private fun getCacheFile() = File(AppGlobal.cacheDir, fileName) 15 | 16 | private suspend fun getCacheData(): String? = withContext(Dispatchers.IO) { 17 | val file = getCacheFile() 18 | if (file.exists()) file.readText() 19 | else null 20 | } 21 | 22 | private suspend fun setCacheData(data: String) = withContext(Dispatchers.IO) { 23 | val file = getCacheFile() 24 | file.writeText(data) 25 | } 26 | 27 | protected suspend fun getOrRefresh(cacheTime: Long, refreshOp: suspend () -> String): String { 28 | return getOrRefresh( 29 | { lastModified, _ -> System.currentTimeMillis() - lastModified >= cacheTime }, 30 | refreshOp, 31 | ) 32 | } 33 | 34 | fun clearCache() { 35 | try { 36 | getCacheFile().delete() 37 | } catch (ex: Exception) { 38 | ex.printStackTrace() 39 | } 40 | } 41 | 42 | protected suspend fun getOrRefresh( 43 | isExpired: (lastModified: Long, cacheData: String?) -> Boolean, 44 | refreshOp: suspend () -> String, 45 | ): String { 46 | var data = getCacheData() 47 | 48 | if (isExpired(getCacheFile().lastModified(), data)) { 49 | data = null 50 | } 51 | 52 | if (data.isNullOrBlank()) { 53 | data = refreshOp() 54 | setCacheData(data) 55 | } 56 | 57 | return data 58 | } 59 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/epg/fetcher/DefaultEpgFetcher.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.epg.fetcher 2 | 3 | import okhttp3.Response 4 | 5 | class DefaultEpgFetcher : EpgFetcher { 6 | override fun isSupport(url: String): Boolean { 7 | return true 8 | } 9 | 10 | override fun fetch(response: Response): String { 11 | return "" 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/epg/fetcher/EpgFetcher.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.epg.fetcher 2 | 3 | import okhttp3.Response 4 | 5 | /** 6 | * 节目单获取接口 7 | */ 8 | interface EpgFetcher { 9 | /** 10 | * 是否支持该格式 11 | */ 12 | fun isSupport(url: String): Boolean 13 | 14 | /** 15 | * 获取节目单 16 | */ 17 | fun fetch(response: Response): String 18 | 19 | companion object { 20 | val instances = listOf( 21 | XmlEpgFetcher(), 22 | XmlGzEpgFetcher(), 23 | DefaultEpgFetcher(), 24 | ) 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/epg/fetcher/XmlEpgFetcher.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.epg.fetcher 2 | 3 | import okhttp3.Response 4 | 5 | class XmlEpgFetcher : EpgFetcher { 6 | override fun isSupport(url: String): Boolean { 7 | return url.endsWith(".xml") 8 | } 9 | 10 | override fun fetch(response: Response): String { 11 | return response.body!!.string() 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/epg/fetcher/XmlGzEpgFetcher.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.epg.fetcher 2 | 3 | import okhttp3.Response 4 | import java.io.BufferedReader 5 | import java.io.ByteArrayInputStream 6 | import java.io.InputStreamReader 7 | import java.util.zip.GZIPInputStream 8 | 9 | class XmlGzEpgFetcher : EpgFetcher { 10 | override fun isSupport(url: String): Boolean { 11 | return url.endsWith(".gz") 12 | } 13 | 14 | override fun fetch(response: Response): String { 15 | val gzData = response.body!!.bytes() 16 | val stringBuilder = StringBuilder() 17 | GZIPInputStream(ByteArrayInputStream(gzData)).use { gzipInputStream -> 18 | BufferedReader(InputStreamReader(gzipInputStream)).use { reader -> 19 | var line: String? 20 | while (reader.readLine().also { line = it } != null) { 21 | stringBuilder.append(line).append("\n") 22 | } 23 | } 24 | } 25 | return stringBuilder.toString() 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/git/GitRepository.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.git 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import okhttp3.OkHttpClient 6 | import okhttp3.Request 7 | import top.yogiczy.mytv.data.repositories.git.parser.GitReleaseParser 8 | import top.yogiczy.mytv.utils.Loggable 9 | 10 | class GitRepository : Loggable() { 11 | 12 | suspend fun latestRelease(url: String) = withContext(Dispatchers.IO) { 13 | log.d("获取最新发行版: $url") 14 | 15 | val client = OkHttpClient() 16 | val request = Request.Builder().url(url).build() 17 | 18 | try { 19 | with(client.newCall(request).execute()) { 20 | if (!isSuccessful) { 21 | throw Exception("获取最新发行版失败: $code") 22 | } 23 | 24 | val parser = GitReleaseParser.instances.first { it.isSupport(url) } 25 | return@with parser.parse(body!!.string()) 26 | } 27 | } catch (ex: Exception) { 28 | log.e("获取最新发行版失败", ex) 29 | throw Exception("获取最新发行版失败,请检查网络连接", ex) 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/git/parser/GitReleaseParser.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.git.parser 2 | 3 | import top.yogiczy.mytv.data.entities.GitRelease 4 | 5 | /** 6 | * git发行版解析 7 | */ 8 | interface GitReleaseParser { 9 | /** 10 | * 是否支持该格式 11 | */ 12 | fun isSupport(url: String): Boolean 13 | 14 | /** 15 | * 解析数据 16 | */ 17 | suspend fun parse(data: String): GitRelease 18 | 19 | companion object { 20 | val instances = listOf( 21 | GithubGitReleaseParser(), 22 | GiteeGitReleaseParser(), 23 | ) 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/git/parser/GiteeGitReleaseParser.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.git.parser 2 | 3 | import kotlinx.serialization.json.Json 4 | import kotlinx.serialization.json.jsonArray 5 | import kotlinx.serialization.json.jsonObject 6 | import kotlinx.serialization.json.jsonPrimitive 7 | import top.yogiczy.mytv.data.entities.GitRelease 8 | 9 | class GiteeGitReleaseParser : GitReleaseParser { 10 | override fun isSupport(url: String): Boolean { 11 | return url.contains("gitee.com") 12 | } 13 | 14 | override suspend fun parse(data: String): GitRelease { 15 | val json = Json.parseToJsonElement(data).jsonObject 16 | 17 | return GitRelease( 18 | version = json.getValue("tag_name").jsonPrimitive.content.substring(1), 19 | downloadUrl = json.getValue("assets").jsonArray[0].jsonObject["browser_download_url"]!!.jsonPrimitive.content, 20 | description = json.getValue("body").jsonPrimitive.content 21 | ) 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/git/parser/GithubGitReleaseParser.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.git.parser 2 | 3 | import kotlinx.serialization.json.Json 4 | import kotlinx.serialization.json.jsonArray 5 | import kotlinx.serialization.json.jsonObject 6 | import kotlinx.serialization.json.jsonPrimitive 7 | import top.yogiczy.mytv.data.entities.GitRelease 8 | import top.yogiczy.mytv.data.utils.Constants 9 | 10 | class GithubGitReleaseParser : GitReleaseParser { 11 | override fun isSupport(url: String): Boolean { 12 | return url.contains("github.com") 13 | } 14 | 15 | override suspend fun parse(data: String): GitRelease { 16 | val json = Json.parseToJsonElement(data).jsonObject 17 | 18 | return GitRelease( 19 | version = json.getValue("tag_name").jsonPrimitive.content.substring(1), 20 | downloadUrl = Constants.GITHUB_PROXY + json.getValue("assets").jsonArray[0].jsonObject["browser_download_url"]!!.jsonPrimitive.content, 21 | description = json.getValue("body").jsonPrimitive.content 22 | ) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/iptv/IptvRepository.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.iptv 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import okhttp3.OkHttpClient 6 | import okhttp3.Request 7 | import top.yogiczy.mytv.data.entities.Iptv 8 | import top.yogiczy.mytv.data.entities.IptvGroup 9 | import top.yogiczy.mytv.data.entities.IptvGroupList 10 | import top.yogiczy.mytv.data.entities.IptvList 11 | import top.yogiczy.mytv.data.repositories.FileCacheRepository 12 | import top.yogiczy.mytv.data.repositories.iptv.parser.IptvParser 13 | import top.yogiczy.mytv.utils.Logger 14 | 15 | /** 16 | * 直播源获取 17 | */ 18 | class IptvRepository : FileCacheRepository("iptv.txt") { 19 | private val log = Logger.create(javaClass.simpleName) 20 | 21 | /** 22 | * 获取远程直播源数据 23 | */ 24 | private suspend fun fetchSource(sourceUrl: String) = withContext(Dispatchers.IO) { 25 | log.d("获取远程直播源: $sourceUrl") 26 | 27 | val client = OkHttpClient() 28 | val request = Request.Builder().url(sourceUrl).build() 29 | 30 | try { 31 | with(client.newCall(request).execute()) { 32 | if (!isSuccessful) { 33 | throw Exception("获取远程直播源失败: $code") 34 | } 35 | 36 | return@with body!!.string() 37 | } 38 | } catch (ex: Exception) { 39 | log.e("获取远程直播源失败", ex) 40 | throw Exception("获取远程直播源失败,请检查网络连接", ex) 41 | } 42 | } 43 | 44 | /** 45 | * 简化规则 46 | */ 47 | private fun simplifyTest(group: IptvGroup, iptv: Iptv): Boolean { 48 | return iptv.name.lowercase().startsWith("cctv") || iptv.name.endsWith("卫视") 49 | } 50 | 51 | /** 52 | * 获取直播源分组列表 53 | */ 54 | suspend fun getIptvGroupList( 55 | sourceUrl: String, 56 | cacheTime: Long, 57 | simplify: Boolean = false, 58 | ): IptvGroupList { 59 | try { 60 | val sourceData = getOrRefresh(cacheTime) { 61 | fetchSource(sourceUrl) 62 | } 63 | 64 | val parser = IptvParser.instances.first { it.isSupport(sourceUrl, sourceData) } 65 | val groupList = parser.parse(sourceData) 66 | log.i("解析直播源完成:${groupList.size}个分组,${groupList.flatMap { it.iptvList }.size}个频道") 67 | 68 | if (simplify) { 69 | return IptvGroupList(groupList.map { group -> 70 | IptvGroup( 71 | name = group.name, iptvList = IptvList(group.iptvList.filter { iptv -> 72 | simplifyTest(group, iptv) 73 | }) 74 | ) 75 | }.filter { it.iptvList.isNotEmpty() }) 76 | } 77 | 78 | return groupList 79 | } catch (ex: Exception) { 80 | log.e("获取直播源失败", ex) 81 | throw Exception(ex) 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/iptv/parser/DefaultIptvParser.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.iptv.parser 2 | 3 | import top.yogiczy.mytv.data.entities.Iptv 4 | import top.yogiczy.mytv.data.entities.IptvGroup 5 | import top.yogiczy.mytv.data.entities.IptvGroupList 6 | import top.yogiczy.mytv.data.entities.IptvList 7 | 8 | class DefaultIptvParser : IptvParser { 9 | 10 | override fun isSupport(url: String, data: String): Boolean { 11 | return true 12 | } 13 | 14 | override suspend fun parse(data: String): IptvGroupList { 15 | return IptvGroupList( 16 | listOf( 17 | IptvGroup( 18 | name = "不支持当前直播源链接格式,请切换其他直播源链接;支持的直播源链接格式:m3u、tvbox", 19 | iptvList = IptvList( 20 | listOf( 21 | Iptv(name = "m3u", channelName = "m3u", urlList = listOf()), 22 | Iptv(name = "tvbox", channelName = "tvbox", urlList = listOf()), 23 | ) 24 | ) 25 | ) 26 | ) 27 | ) 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/iptv/parser/IptvParser.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.iptv.parser 2 | 3 | import top.yogiczy.mytv.data.entities.IptvGroupList 4 | 5 | /** 6 | * 直播源数据解析接口 7 | */ 8 | interface IptvParser { 9 | /** 10 | * 是否支持该直播源格式 11 | */ 12 | fun isSupport(url: String, data: String): Boolean 13 | 14 | /** 15 | * 解析直播源数据 16 | */ 17 | suspend fun parse(data: String): IptvGroupList 18 | 19 | companion object { 20 | val instances = listOf( 21 | M3uIptvParser(), 22 | TvboxIptvParser(), 23 | DefaultIptvParser(), 24 | ) 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/iptv/parser/M3uIptvParser.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.iptv.parser 2 | 3 | import top.yogiczy.mytv.data.entities.Iptv 4 | import top.yogiczy.mytv.data.entities.IptvGroup 5 | import top.yogiczy.mytv.data.entities.IptvGroupList 6 | import top.yogiczy.mytv.data.entities.IptvList 7 | 8 | class M3uIptvParser : IptvParser { 9 | 10 | override fun isSupport(url: String, data: String): Boolean { 11 | return data.startsWith("#EXTM3U") 12 | } 13 | 14 | override suspend fun parse(data: String): IptvGroupList { 15 | val lines = data.split("\r\n", "\n") 16 | val iptvList = mutableListOf() 17 | 18 | lines.forEachIndexed { index, line -> 19 | if (!line.startsWith("#EXTINF")) return@forEachIndexed 20 | 21 | val name = line.split(",").last() 22 | val channelName = Regex("tvg-name=\"(.+?)\"").find(line)?.groupValues?.get(1) ?: name 23 | val groupName = Regex("group-title=\"(.+?)\"").find(line)?.groupValues?.get(1) ?: "其他" 24 | 25 | iptvList.add( 26 | IptvResponseItem( 27 | name = name.trim(), 28 | channelName = channelName.trim(), 29 | groupName = groupName.trim(), 30 | url = lines[index + 1].trim(), 31 | ) 32 | ) 33 | } 34 | 35 | return IptvGroupList(iptvList.groupBy { it.groupName }.map { groupEntry -> 36 | IptvGroup( 37 | name = groupEntry.key, 38 | iptvList = IptvList(groupEntry.value.groupBy { it.name }.map { nameEntry -> 39 | Iptv( 40 | name = nameEntry.key, 41 | channelName = nameEntry.value.first().channelName, 42 | urlList = nameEntry.value.map { it.url }, 43 | ) 44 | }) 45 | ) 46 | }) 47 | } 48 | 49 | private data class IptvResponseItem( 50 | val name: String, 51 | val channelName: String, 52 | val groupName: String, 53 | val url: String, 54 | ) 55 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/repositories/iptv/parser/TvboxIptvParser.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.repositories.iptv.parser 2 | 3 | import top.yogiczy.mytv.data.entities.Iptv 4 | import top.yogiczy.mytv.data.entities.IptvGroup 5 | import top.yogiczy.mytv.data.entities.IptvGroupList 6 | import top.yogiczy.mytv.data.entities.IptvList 7 | 8 | class TvboxIptvParser : IptvParser { 9 | 10 | override fun isSupport(url: String, data: String): Boolean { 11 | return data.contains("#genre#") 12 | } 13 | 14 | override suspend fun parse(data: String): IptvGroupList { 15 | val lines = data.split("\r\n", "\n") 16 | val iptvList = mutableListOf() 17 | 18 | var groupName: String? = null 19 | lines.forEach { line -> 20 | if (line.isBlank() || line.startsWith("#")) return@forEach 21 | 22 | if (line.contains("#genre#")) { 23 | groupName = line.split(",").first() 24 | } else { 25 | val res = line.replace(",", ",").split(",") 26 | if (res.size < 2) return@forEach 27 | 28 | iptvList.addAll(res[1].split("#").map { url -> 29 | IptvResponseItem( 30 | name = res[0].trim(), 31 | channelName = res[0].trim(), 32 | groupName = groupName?.trim() ?: "其他", 33 | url = url.trim(), 34 | ) 35 | }) 36 | } 37 | } 38 | 39 | return IptvGroupList(iptvList.groupBy { it.groupName }.map { groupEntry -> 40 | IptvGroup( 41 | name = groupEntry.key, 42 | iptvList = IptvList(groupEntry.value.groupBy { it.name }.map { nameEntry -> 43 | Iptv( 44 | name = nameEntry.key, 45 | channelName = nameEntry.value.first().channelName, 46 | urlList = nameEntry.value.map { it.url }, 47 | ) 48 | }), 49 | ) 50 | }) 51 | } 52 | 53 | private data class IptvResponseItem( 54 | val name: String, 55 | val channelName: String, 56 | val groupName: String, 57 | val url: String, 58 | ) 59 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/data/utils/Constants.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.data.utils 2 | 3 | /** 4 | * 常量 5 | */ 6 | object Constants { 7 | /** 8 | * 应用 标题 9 | */ 10 | const val APP_TITLE = "我的电视" 11 | 12 | /** 13 | * 应用 代码仓库 14 | */ 15 | const val APP_REPO = "https://github.com/yaoxieyoulei/mytv-android" 16 | 17 | /** 18 | * IPTV源地址 19 | */ 20 | const val IPTV_SOURCE_URL = "http://1.2.3.4/live.m3u" 21 | 22 | /** 23 | * IPTV源缓存时间(毫秒) 24 | */ 25 | const val IPTV_SOURCE_CACHE_TIME = 1000 * 60 * 60 * 24L // 24小时 26 | 27 | /** 28 | * 节目单XML地址 29 | */ 30 | const val EPG_XML_URL = "http://epg.51zmt.top:8000/e.xml.gz" 31 | 32 | /** 33 | * 节目单刷新时间阈值(小时) 34 | */ 35 | const val EPG_REFRESH_TIME_THRESHOLD = 2 // 不到2点不刷新 36 | 37 | /** 38 | * Git最新版本信息 39 | */ 40 | const val GIT_RELEASE_LATEST_URL = 41 | "https://api.github.com/repos/yaoxieyoulei/mytv-android/releases/latest" 42 | 43 | /** 44 | * GitHub加速代理地址 45 | */ 46 | const val GITHUB_PROXY = "https://mirror.ghproxy.com/" 47 | 48 | /** 49 | * HTTP请求重试次数 50 | */ 51 | const val HTTP_RETRY_COUNT = 10L 52 | 53 | /** 54 | * HTTP请求重试间隔时间(毫秒) 55 | */ 56 | const val HTTP_RETRY_INTERVAL = 3000L 57 | 58 | /** 59 | * 播放器 userAgent 60 | */ 61 | const val VIDEO_PLAYER_USER_AGENT = "ExoPlayer" 62 | 63 | /** 64 | * 日志历史最大保留条数 65 | */ 66 | const val LOG_HISTORY_MAX_SIZE = 50 67 | 68 | /** 69 | * 播放器加载超时 70 | */ 71 | const val VIDEO_PLAYER_LOAD_TIMEOUT = 1000L * 15 // 15秒 72 | 73 | /** 74 | * 界面 超时未操作自动关闭界面 75 | */ 76 | const val UI_SCREEN_AUTO_CLOSE_DELAY = 1000L * 15 // 15秒 77 | 78 | /** 79 | * 界面 时间显示前后范围 80 | */ 81 | const val UI_TIME_SHOW_RANGE = 1000L * 30 // 前后30秒 82 | 83 | /** 84 | * 界面 临时面板界面显示时间 85 | */ 86 | const val UI_TEMP_PANEL_SCREEN_SHOW_DURATION = 1500L // 1.5秒 87 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/LeanbackApp.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui 2 | 3 | import androidx.annotation.IntRange 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.calculateEndPadding 6 | import androidx.compose.foundation.layout.calculateStartPadding 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.LaunchedEffect 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.platform.LocalLayoutDirection 16 | import androidx.compose.ui.unit.LayoutDirection 17 | import androidx.compose.ui.unit.dp 18 | import kotlinx.coroutines.FlowPreview 19 | import kotlinx.coroutines.channels.Channel 20 | import kotlinx.coroutines.flow.consumeAsFlow 21 | import kotlinx.coroutines.flow.debounce 22 | import top.yogiczy.mytv.ui.screens.leanback.components.LeanbackPadding 23 | import top.yogiczy.mytv.ui.screens.leanback.main.LeanbackMainScreen 24 | import top.yogiczy.mytv.ui.screens.leanback.toast.LeanbackToastScreen 25 | import top.yogiczy.mytv.ui.screens.leanback.toast.LeanbackToastState 26 | 27 | @Composable 28 | fun LeanbackApp( 29 | modifier: Modifier = Modifier, 30 | onBackPressed: () -> Unit = {}, 31 | ) { 32 | val context = LocalContext.current 33 | val doubleBackPressedExitState = rememberLeanbackDoubleBackPressedExitState() 34 | 35 | LeanbackToastScreen() 36 | LeanbackMainScreen( 37 | modifier = modifier, 38 | onBackPressed = { 39 | if (doubleBackPressedExitState.allowExit) { 40 | onBackPressed() 41 | } else { 42 | doubleBackPressedExitState.backPress() 43 | LeanbackToastState.I.showToast("再按一次退出") 44 | } 45 | }, 46 | ) 47 | } 48 | 49 | 50 | /** 51 | * 退出应用二次确认 52 | */ 53 | class LeanbackDoubleBackPressedExitState internal constructor( 54 | @IntRange(from = 0) 55 | private val resetSeconds: Int, 56 | ) { 57 | private var _allowExit by mutableStateOf(false) 58 | val allowExit get() = _allowExit 59 | 60 | fun backPress() { 61 | _allowExit = true 62 | channel.trySend(resetSeconds) 63 | } 64 | 65 | private val channel = Channel(Channel.CONFLATED) 66 | 67 | @OptIn(FlowPreview::class) 68 | suspend fun observe() { 69 | channel.consumeAsFlow() 70 | .debounce { it.toLong() * 1000 } 71 | .collect { _allowExit = false } 72 | } 73 | } 74 | 75 | /** 76 | * 退出应用二次确认状态 77 | */ 78 | @Composable 79 | fun rememberLeanbackDoubleBackPressedExitState(@IntRange(from = 0) resetSeconds: Int = 2) = 80 | remember { LeanbackDoubleBackPressedExitState(resetSeconds = resetSeconds) } 81 | .also { LaunchedEffect(it) { it.observe() } } 82 | 83 | val LeanbackParentPadding = PaddingValues(vertical = 12.dp, horizontal = 24.dp) 84 | 85 | @Composable 86 | fun rememberLeanbackChildPadding(direction: LayoutDirection = LocalLayoutDirection.current) = 87 | remember { 88 | LeanbackPadding( 89 | start = LeanbackParentPadding.calculateStartPadding(direction) + 8.dp, 90 | top = LeanbackParentPadding.calculateTopPadding(), 91 | end = LeanbackParentPadding.calculateEndPadding(direction) + 8.dp, 92 | bottom = LeanbackParentPadding.calculateBottomPadding() 93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/components/Padding.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.components 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.ui.unit.Dp 5 | 6 | @Immutable 7 | data class LeanbackPadding( 8 | val start: Dp, 9 | val top: Dp, 10 | val end: Dp, 11 | val bottom: Dp, 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/components/Qrcode.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.components 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.material3.AlertDialog 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.unit.dp 17 | import androidx.compose.ui.window.DialogProperties 18 | import io.github.alexzhirkevich.qrose.options.QrBallShape 19 | import io.github.alexzhirkevich.qrose.options.QrFrameShape 20 | import io.github.alexzhirkevich.qrose.options.QrPixelShape 21 | import io.github.alexzhirkevich.qrose.options.QrShapes 22 | import io.github.alexzhirkevich.qrose.options.circle 23 | import io.github.alexzhirkevich.qrose.options.roundCorners 24 | import io.github.alexzhirkevich.qrose.rememberQrCodePainter 25 | 26 | @Composable 27 | fun LeanbackQrcode( 28 | modifier: Modifier = Modifier, 29 | text: String, 30 | ) { 31 | Box( 32 | modifier = modifier 33 | .background( 34 | color = MaterialTheme.colorScheme.onBackground, 35 | shape = MaterialTheme.shapes.medium, 36 | ) 37 | ) { 38 | Image( 39 | modifier = Modifier 40 | .fillMaxSize() 41 | .align(Alignment.Center) 42 | .padding(10.dp), 43 | painter = rememberQrCodePainter( 44 | data = text, 45 | shapes = QrShapes( 46 | ball = QrBallShape.circle(), 47 | darkPixel = QrPixelShape.roundCorners(), 48 | frame = QrFrameShape.roundCorners(.25f), 49 | ), 50 | ), 51 | contentDescription = text, 52 | ) 53 | } 54 | } 55 | 56 | @Composable 57 | fun LeanbackQrcodeDialog( 58 | modifier: Modifier = Modifier, 59 | text: String, 60 | description: String? = null, 61 | showDialogProvider: () -> Boolean = { false }, 62 | onDismissRequest: () -> Unit = {}, 63 | ) { 64 | if (showDialogProvider()) { 65 | AlertDialog( 66 | modifier = modifier, 67 | properties = DialogProperties(usePlatformDefaultWidth = false), 68 | onDismissRequest = onDismissRequest, 69 | confirmButton = { description?.let { Text(text = description) } }, 70 | text = { 71 | LeanbackQrcode( 72 | text = text, 73 | modifier = Modifier 74 | .width(240.dp) 75 | .height(240.dp), 76 | ) 77 | }, 78 | ) 79 | } 80 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/components/Visible.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.components 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | fun LeanbackVisible( 7 | visibleProvider: () -> Boolean = { false }, 8 | content: @Composable () -> Unit 9 | ) { 10 | if (visibleProvider()) { 11 | content() 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.main 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import kotlinx.coroutines.delay 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.asStateFlow 9 | import kotlinx.coroutines.flow.catch 10 | import kotlinx.coroutines.flow.collect 11 | import kotlinx.coroutines.flow.flow 12 | import kotlinx.coroutines.flow.map 13 | import kotlinx.coroutines.flow.retry 14 | import kotlinx.coroutines.flow.retryWhen 15 | import kotlinx.coroutines.launch 16 | import top.yogiczy.mytv.data.entities.EpgList 17 | import top.yogiczy.mytv.data.entities.IptvGroupList 18 | import top.yogiczy.mytv.data.entities.IptvGroupList.Companion.iptvList 19 | import top.yogiczy.mytv.data.repositories.epg.EpgRepository 20 | import top.yogiczy.mytv.data.repositories.iptv.IptvRepository 21 | import top.yogiczy.mytv.data.utils.Constants 22 | import top.yogiczy.mytv.ui.utils.SP 23 | 24 | class LeanbackMainViewModel : ViewModel() { 25 | private val iptvRepository = IptvRepository() 26 | private val epgRepository = EpgRepository() 27 | 28 | private val _uiState = MutableStateFlow(LeanbackMainUiState.Loading()) 29 | val uiState: StateFlow = _uiState.asStateFlow() 30 | 31 | init { 32 | viewModelScope.launch { 33 | refreshIptv() 34 | refreshEpg() 35 | } 36 | } 37 | 38 | private suspend fun refreshIptv() { 39 | flow { 40 | emit( 41 | iptvRepository.getIptvGroupList( 42 | sourceUrl = SP.iptvSourceUrl, 43 | cacheTime = SP.iptvSourceCacheTime, 44 | simplify = SP.iptvSourceSimplify, 45 | ) 46 | ) 47 | } 48 | .retryWhen { _, attempt -> 49 | if (attempt >= Constants.HTTP_RETRY_COUNT) return@retryWhen false 50 | 51 | _uiState.value = 52 | LeanbackMainUiState.Loading("获取远程直播源(${attempt + 1}/${Constants.HTTP_RETRY_COUNT})...") 53 | delay(Constants.HTTP_RETRY_INTERVAL) 54 | true 55 | } 56 | .catch { 57 | _uiState.value = LeanbackMainUiState.Error(it.message) 58 | SP.iptvSourceUrlHistoryList -= SP.iptvSourceUrl 59 | } 60 | .map { 61 | _uiState.value = LeanbackMainUiState.Ready(iptvGroupList = it) 62 | SP.iptvSourceUrlHistoryList += SP.iptvSourceUrl 63 | it 64 | } 65 | .collect() 66 | } 67 | 68 | private suspend fun refreshEpg() { 69 | if (_uiState.value is LeanbackMainUiState.Ready) { 70 | val iptvGroupList = (_uiState.value as LeanbackMainUiState.Ready).iptvGroupList 71 | 72 | flow { 73 | emit( 74 | epgRepository.getEpgList( 75 | xmlUrl = SP.epgXmlUrl, 76 | filteredChannels = iptvGroupList.iptvList.map { it.channelName }, 77 | refreshTimeThreshold = SP.epgRefreshTimeThreshold, 78 | ) 79 | ) 80 | } 81 | .retry(Constants.HTTP_RETRY_COUNT) { delay(Constants.HTTP_RETRY_INTERVAL); true } 82 | .catch { 83 | emit(EpgList()) 84 | SP.epgXmlUrlHistoryList -= SP.epgXmlUrl 85 | } 86 | .map { epgList -> 87 | _uiState.value = 88 | (_uiState.value as LeanbackMainUiState.Ready).copy(epgList = epgList) 89 | SP.epgXmlUrlHistoryList += SP.epgXmlUrl 90 | } 91 | .collect() 92 | } 93 | } 94 | } 95 | 96 | sealed interface LeanbackMainUiState { 97 | data class Loading(val message: String? = null) : LeanbackMainUiState 98 | data class Error(val message: String? = null) : LeanbackMainUiState 99 | data class Ready( 100 | val iptvGroupList: IptvGroupList = IptvGroupList(), 101 | val epgList: EpgList = EpgList(), 102 | ) : LeanbackMainUiState 103 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/panel/PanelAutoCloseState.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.panel 2 | 3 | import androidx.annotation.IntRange 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.Stable 7 | import androidx.compose.runtime.remember 8 | import kotlinx.coroutines.FlowPreview 9 | import kotlinx.coroutines.channels.Channel 10 | import kotlinx.coroutines.flow.consumeAsFlow 11 | import kotlinx.coroutines.flow.debounce 12 | 13 | @Stable 14 | class PanelAutoCloseState internal constructor( 15 | @IntRange(from = 0) private val timeout: Long, 16 | private val onTimeout: () -> Unit = {}, 17 | ) { 18 | fun active() { 19 | channel.trySend(timeout) 20 | } 21 | 22 | private val channel = Channel(Channel.CONFLATED) 23 | 24 | @OptIn(FlowPreview::class) 25 | suspend fun observe() { 26 | channel.consumeAsFlow().debounce { it }.collect { 27 | onTimeout() 28 | } 29 | } 30 | } 31 | 32 | @Composable 33 | fun rememberPanelAutoCloseState( 34 | @IntRange(from = 0) timeout: Long = 1000L * 15, 35 | onTimeout: () -> Unit = {}, 36 | ) = remember { PanelAutoCloseState(timeout = timeout, onTimeout = onTimeout) }.also { 37 | LaunchedEffect(it) { it.observe() } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/panel/PanelChannelNoSelectScreen.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.panel 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.LaunchedEffect 8 | import androidx.compose.runtime.Stable 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import kotlinx.coroutines.FlowPreview 17 | import kotlinx.coroutines.channels.Channel 18 | import kotlinx.coroutines.flow.consumeAsFlow 19 | import kotlinx.coroutines.flow.debounce 20 | import top.yogiczy.mytv.ui.rememberLeanbackChildPadding 21 | import top.yogiczy.mytv.ui.screens.leanback.panel.components.LeanbackPanelChannelNo 22 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 23 | 24 | @Composable 25 | fun LeanbackPanelChannelNoSelectScreen( 26 | modifier: Modifier = Modifier, 27 | channelNoProvider: () -> String = { "" }, 28 | ) { 29 | val childPadding = rememberLeanbackChildPadding() 30 | 31 | Box(modifier = modifier.fillMaxSize()) { 32 | LeanbackPanelChannelNo( 33 | channelNoProvider = channelNoProvider, 34 | modifier = Modifier 35 | .padding(top = childPadding.top, end = childPadding.end) 36 | .align(Alignment.TopEnd), 37 | ) 38 | } 39 | } 40 | 41 | @Preview(device = "id:Android TV (720p)") 42 | @Composable 43 | private fun LeanbackPanelChannelNoSelectScreenPreview() { 44 | LeanbackTheme { 45 | LeanbackPanelChannelNoSelectScreen( 46 | channelNoProvider = { "01" } 47 | ) 48 | } 49 | } 50 | 51 | @Stable 52 | class LeanbackPanelChannelNoSelectState( 53 | private val onChannelNoConfirm: (String) -> Unit = {}, 54 | initialChannelNo: String = "", 55 | ) { 56 | private var _channelNo by mutableStateOf(initialChannelNo) 57 | val channelNo get() = _channelNo 58 | 59 | fun input(no: Int) { 60 | _channelNo += no.toString() 61 | channel.trySend(_channelNo) 62 | } 63 | 64 | private val channel = Channel(Channel.CONFLATED) 65 | 66 | @OptIn(FlowPreview::class) 67 | suspend fun observe() { 68 | channel.consumeAsFlow().debounce { (4 - it.length) * 1000L }.collect { 69 | onChannelNoConfirm(it) 70 | _channelNo = "" 71 | } 72 | } 73 | } 74 | 75 | @Composable 76 | fun rememberLeanbackPanelChannelNoSelectState( 77 | onChannelNoConfirm: (String) -> Unit = {}, 78 | ) = remember { 79 | LeanbackPanelChannelNoSelectState(onChannelNoConfirm) 80 | }.also { LaunchedEffect(it) { it.observe() } } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/panel/PanelDateTimeScreen.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.panel 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.LaunchedEffect 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.runtime.setValue 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.compose.ui.unit.dp 19 | import kotlinx.coroutines.delay 20 | import top.yogiczy.mytv.data.utils.Constants 21 | import top.yogiczy.mytv.ui.rememberLeanbackChildPadding 22 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 23 | import top.yogiczy.mytv.ui.utils.SP 24 | import java.text.SimpleDateFormat 25 | import java.util.Locale 26 | 27 | @Composable 28 | fun LeanbackPanelDateTimeScreen( 29 | modifier: Modifier = Modifier, 30 | showModeProvider: () -> SP.UiTimeShowMode = { SP.UiTimeShowMode.HIDDEN }, 31 | ) { 32 | val childPadding = rememberLeanbackChildPadding() 33 | 34 | var timeText by remember { mutableStateOf("") } 35 | var visible by remember { mutableStateOf(false) } 36 | LaunchedEffect(Unit) { 37 | while (true) { 38 | val timestamp = System.currentTimeMillis() 39 | 40 | visible = when (showModeProvider()) { 41 | SP.UiTimeShowMode.HIDDEN -> false 42 | SP.UiTimeShowMode.ALWAYS -> true 43 | 44 | SP.UiTimeShowMode.EVERY_HOUR -> { 45 | timestamp % 3600000 <= (Constants.UI_TIME_SHOW_RANGE + 1000) || timestamp % 3600000 >= 3600000 - Constants.UI_TIME_SHOW_RANGE 46 | } 47 | 48 | SP.UiTimeShowMode.HALF_HOUR -> { 49 | timestamp % 1800000 <= (Constants.UI_TIME_SHOW_RANGE + 1000) || timestamp % 1800000 >= 1800000 - Constants.UI_TIME_SHOW_RANGE 50 | } 51 | } 52 | 53 | if (visible) { 54 | timeText = when (showModeProvider()) { 55 | SP.UiTimeShowMode.ALWAYS -> SimpleDateFormat("HH:mm", Locale.getDefault()) 56 | else -> SimpleDateFormat("HH:mm:ss", Locale.getDefault()) 57 | }.format(timestamp) 58 | } 59 | 60 | delay(1000) 61 | } 62 | } 63 | 64 | Box(modifier = modifier.fillMaxSize()) { 65 | if (visible) { 66 | Text( 67 | text = timeText, 68 | style = MaterialTheme.typography.titleLarge, 69 | modifier = Modifier 70 | .align(Alignment.TopEnd) 71 | .padding(top = childPadding.top, end = childPadding.end) 72 | .background( 73 | color = MaterialTheme.colorScheme.surface.copy(0.8f), 74 | shape = MaterialTheme.shapes.small, 75 | ) 76 | .padding(horizontal = 8.dp, vertical = 4.dp), 77 | ) 78 | } 79 | } 80 | } 81 | 82 | @Preview(device = "id:Android TV (720p)") 83 | @Composable 84 | private fun LeanbackPanelDateTimeScreenPreview() { 85 | LeanbackTheme { 86 | LeanbackPanelDateTimeScreen(showModeProvider = { SP.UiTimeShowMode.ALWAYS }) 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/panel/components/PanelChannelNo.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.panel.components 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.tooling.preview.Preview 8 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 9 | 10 | @Composable 11 | fun LeanbackPanelChannelNo( 12 | modifier: Modifier = Modifier, 13 | channelNoProvider: () -> String = { "" }, 14 | ) { 15 | Text( 16 | modifier = modifier, 17 | text = channelNoProvider(), 18 | style = MaterialTheme.typography.displayMedium, 19 | color = MaterialTheme.colorScheme.onBackground, 20 | ) 21 | } 22 | 23 | @Preview 24 | @Composable 25 | private fun LeanbackPanelChannelNoPreview() { 26 | LeanbackTheme { 27 | LeanbackPanelChannelNo( 28 | channelNoProvider = { "01" } 29 | ) 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/panel/components/PanelDateTime.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.panel.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.LaunchedEffect 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.mutableLongStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.setValue 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import kotlinx.coroutines.delay 16 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 17 | import java.text.SimpleDateFormat 18 | import java.util.Locale 19 | 20 | @Composable 21 | fun LeanbackPanelDateTime( 22 | modifier: Modifier = Modifier, 23 | timestamp: Long = rememberTimestamp(), 24 | ) { 25 | val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) 26 | val dateFormat = SimpleDateFormat("MM/dd EEE", Locale.getDefault()) 27 | 28 | Column( 29 | modifier = modifier, 30 | horizontalAlignment = Alignment.CenterHorizontally, 31 | ) { 32 | Text( 33 | text = dateFormat.format(timestamp), 34 | style = MaterialTheme.typography.labelSmall, 35 | color = MaterialTheme.colorScheme.onBackground, 36 | ) 37 | Text( 38 | text = timeFormat.format(timestamp), 39 | style = MaterialTheme.typography.titleLarge, 40 | color = MaterialTheme.colorScheme.onBackground, 41 | ) 42 | } 43 | } 44 | 45 | @Preview 46 | @Composable 47 | private fun LeanbackPanelDateTimePreview() { 48 | LeanbackTheme { 49 | LeanbackPanelDateTime() 50 | } 51 | } 52 | 53 | @Composable 54 | private fun rememberTimestamp(): Long { 55 | var timestamp by remember { mutableLongStateOf(System.currentTimeMillis()) } 56 | 57 | LaunchedEffect(Unit) { 58 | while (true) { 59 | delay(1000) 60 | timestamp = System.currentTimeMillis() 61 | } 62 | } 63 | 64 | return timestamp 65 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/panel/components/PanelIptvGroupList.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.panel.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.LocalContentColor 11 | import androidx.compose.material3.LocalTextStyle 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.CompositionLocalProvider 16 | import androidx.compose.runtime.LaunchedEffect 17 | import androidx.compose.runtime.snapshotFlow 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.tooling.preview.Preview 20 | import androidx.compose.ui.unit.dp 21 | import androidx.tv.foundation.lazy.list.TvLazyColumn 22 | import androidx.tv.foundation.lazy.list.itemsIndexed 23 | import androidx.tv.foundation.lazy.list.rememberTvLazyListState 24 | import kotlinx.coroutines.flow.distinctUntilChanged 25 | import top.yogiczy.mytv.data.entities.EpgList 26 | import top.yogiczy.mytv.data.entities.Iptv 27 | import top.yogiczy.mytv.data.entities.IptvGroupList 28 | import top.yogiczy.mytv.data.entities.IptvGroupList.Companion.iptvGroupIdx 29 | import top.yogiczy.mytv.ui.rememberLeanbackChildPadding 30 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 31 | import top.yogiczy.mytv.ui.utils.handleLeanbackKeyEvents 32 | import kotlin.math.max 33 | 34 | @Composable 35 | fun LeanbackPanelIptvGroupList( 36 | modifier: Modifier = Modifier, 37 | iptvGroupListProvider: () -> IptvGroupList = { IptvGroupList() }, 38 | epgListProvider: () -> EpgList = { EpgList() }, 39 | currentIptvProvider: () -> Iptv = { Iptv() }, 40 | showProgrammeProgressProvider: () -> Boolean = { false }, 41 | onIptvSelected: (Iptv) -> Unit = {}, 42 | onIptvFavoriteToggle: (Iptv) -> Unit = {}, 43 | onToFavorite: () -> Unit = {}, 44 | onUserAction: () -> Unit = {}, 45 | ) { 46 | val iptvGroupList = iptvGroupListProvider() 47 | 48 | val listState = 49 | rememberTvLazyListState(max(0, iptvGroupList.iptvGroupIdx(currentIptvProvider()))) 50 | val childPadding = rememberLeanbackChildPadding() 51 | 52 | LaunchedEffect(listState) { 53 | snapshotFlow { listState.isScrollInProgress } 54 | .distinctUntilChanged() 55 | .collect { _ -> onUserAction() } 56 | } 57 | 58 | TvLazyColumn( 59 | modifier = modifier, 60 | state = listState, 61 | verticalArrangement = Arrangement.spacedBy(14.dp), 62 | contentPadding = PaddingValues(bottom = childPadding.bottom), 63 | ) { 64 | itemsIndexed(iptvGroupList) { index, iptvGroup -> 65 | Row( 66 | modifier = Modifier.padding(start = childPadding.start), 67 | horizontalArrangement = Arrangement.spacedBy(6.dp), 68 | ) { 69 | CompositionLocalProvider( 70 | LocalTextStyle provides MaterialTheme.typography.labelMedium, 71 | ) { 72 | Text(text = iptvGroup.name) 73 | Text( 74 | text = "${iptvGroup.iptvList.size}个频道", 75 | color = LocalContentColor.current.copy(alpha = 0.8f), 76 | ) 77 | } 78 | } 79 | 80 | Spacer(modifier = Modifier.height(6.dp)) 81 | 82 | LeanbackPanelIptvList( 83 | modifier = if (index == 0) { 84 | Modifier.handleLeanbackKeyEvents(onUp = { onToFavorite() }) 85 | } else Modifier, 86 | iptvListProvider = { iptvGroup.iptvList }, 87 | epgListProvider = epgListProvider, 88 | currentIptvProvider = currentIptvProvider, 89 | showProgrammeProgressProvider = showProgrammeProgressProvider, 90 | onIptvSelected = onIptvSelected, 91 | onIptvFavoriteToggle = onIptvFavoriteToggle, 92 | onUserAction = onUserAction, 93 | ) 94 | } 95 | } 96 | } 97 | 98 | @Preview(device = "id:Android TV (720p)") 99 | @Composable 100 | private fun LeanbackPanelIptvGroupListPreview() { 101 | LeanbackTheme { 102 | Box(modifier = Modifier.height(150.dp)) { 103 | LeanbackPanelIptvGroupList( 104 | iptvGroupListProvider = { IptvGroupList.EXAMPLE }, 105 | currentIptvProvider = { Iptv.EXAMPLE }, 106 | ) 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/panel/components/PanelIptvInfo.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.panel.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.material3.LocalContentColor 11 | import androidx.compose.material3.LocalTextStyle 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.CompositionLocalProvider 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.tooling.preview.Preview 19 | import androidx.compose.ui.unit.dp 20 | import top.yogiczy.mytv.data.entities.EpgProgrammeCurrent 21 | import top.yogiczy.mytv.data.entities.Iptv 22 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 23 | import top.yogiczy.mytv.utils.isIPv6 24 | 25 | @Composable 26 | fun LeanbackPanelIptvInfo( 27 | modifier: Modifier = Modifier, 28 | iptvProvider: () -> Iptv = { Iptv() }, 29 | iptvUrlIdxProvider: () -> Int = { 0 }, 30 | currentProgrammesProvider: () -> EpgProgrammeCurrent? = { null }, 31 | ) { 32 | val iptv = iptvProvider() 33 | val iptvUrlIdx = iptvUrlIdxProvider() 34 | val currentProgrammes = currentProgrammesProvider() 35 | 36 | Column(modifier = modifier) { 37 | Row(verticalAlignment = Alignment.Bottom) { 38 | Text( 39 | text = iptv.name, 40 | style = MaterialTheme.typography.headlineLarge, 41 | modifier = Modifier.alignByBaseline(), 42 | maxLines = 1, 43 | ) 44 | 45 | Spacer(modifier = Modifier.width(6.dp)) 46 | 47 | Row( 48 | // FIXME 没对齐,临时解决 49 | modifier = Modifier.padding(bottom = 6.dp), 50 | horizontalArrangement = Arrangement.spacedBy(4.dp), 51 | ) { 52 | CompositionLocalProvider( 53 | LocalTextStyle provides MaterialTheme.typography.labelMedium, 54 | LocalContentColor provides LocalContentColor.current.copy(alpha = 0.8f), 55 | ) { 56 | val textModifier = Modifier 57 | .background( 58 | LocalContentColor.current.copy(alpha = 0.3f), 59 | MaterialTheme.shapes.extraSmall, 60 | ) 61 | .padding(vertical = 2.dp, horizontal = 4.dp) 62 | 63 | // 多线路标识 64 | if (iptv.urlList.size > 1) { 65 | Text( 66 | text = "${iptvUrlIdx + 1}/${iptv.urlList.size}", 67 | modifier = textModifier, 68 | ) 69 | } 70 | 71 | // ipv4、iptv6标识 72 | Text( 73 | text = if (iptv.urlList[iptvUrlIdx].isIPv6()) "IPV6" else "IPV4", 74 | modifier = textModifier, 75 | ) 76 | } 77 | } 78 | } 79 | 80 | CompositionLocalProvider( 81 | LocalTextStyle provides MaterialTheme.typography.bodyLarge, 82 | LocalContentColor provides LocalContentColor.current.copy(alpha = 0.8f), 83 | ) { 84 | Text( 85 | text = "正在播放:${currentProgrammes?.now?.title ?: "无节目"}", 86 | maxLines = 1, 87 | ) 88 | Text( 89 | text = "稍后播放:${currentProgrammes?.next?.title ?: "无节目"}", 90 | maxLines = 1, 91 | ) 92 | } 93 | } 94 | } 95 | 96 | @Preview 97 | @Composable 98 | private fun LeanbackPanelIptvInfoPreview() { 99 | LeanbackTheme { 100 | LeanbackPanelIptvInfo( 101 | iptvProvider = { Iptv.EXAMPLE }, 102 | iptvUrlIdxProvider = { 1 }, 103 | currentProgrammesProvider = { EpgProgrammeCurrent.EXAMPLE }, 104 | ) 105 | } 106 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/panel/components/PanelPlayerInfo.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.panel.components 2 | 3 | import android.net.TrafficStats 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.material3.LocalContentColor 8 | import androidx.compose.material3.LocalTextStyle 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.CompositionLocalProvider 13 | import androidx.compose.runtime.LaunchedEffect 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.mutableLongStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.tooling.preview.Preview 20 | import androidx.compose.ui.unit.dp 21 | import kotlinx.coroutines.delay 22 | import top.yogiczy.mytv.ui.screens.leanback.video.player.LeanbackVideoPlayer 23 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 24 | import java.text.DecimalFormat 25 | 26 | @Composable 27 | fun LeanbackPanelPlayerInfo( 28 | modifier: Modifier = Modifier, 29 | metadataProvider: () -> LeanbackVideoPlayer.Metadata = { LeanbackVideoPlayer.Metadata() }, 30 | ) { 31 | CompositionLocalProvider( 32 | LocalTextStyle provides MaterialTheme.typography.bodyLarge, 33 | LocalContentColor provides MaterialTheme.colorScheme.onBackground 34 | ) { 35 | Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) { 36 | PanelPlayerInfoResolution( 37 | resolutionProvider = { 38 | val metadata = metadataProvider() 39 | metadata.videoWidth to metadata.videoHeight 40 | } 41 | ) 42 | 43 | PanelPlayerInfoNetSpeed() 44 | } 45 | } 46 | } 47 | 48 | @Composable 49 | private fun PanelPlayerInfoResolution( 50 | modifier: Modifier = Modifier, 51 | resolutionProvider: () -> Pair = { 0 to 0 }, 52 | ) { 53 | val resolution = resolutionProvider() 54 | 55 | Text( 56 | text = "分辨率:${resolution.first}×${resolution.second}", 57 | modifier = modifier, 58 | ) 59 | } 60 | 61 | @Composable 62 | private fun PanelPlayerInfoNetSpeed( 63 | modifier: Modifier = Modifier, 64 | netSpeed: Long = rememberNetSpeed(), 65 | ) { 66 | Text( 67 | text = if (netSpeed < 1024 * 999) "网速:${netSpeed / 1024}KB/s" 68 | else "网速:${DecimalFormat("#.#").format(netSpeed / 1024 / 1024f)}MB/s", 69 | modifier = modifier, 70 | ) 71 | } 72 | 73 | @Composable 74 | private fun rememberNetSpeed(): Long { 75 | var netSpeed by remember { mutableLongStateOf(0) } 76 | 77 | LaunchedEffect(Unit) { 78 | var lastTotalRxBytes = TrafficStats.getTotalRxBytes() 79 | var lastTimeStamp = System.currentTimeMillis() 80 | 81 | while (true) { 82 | delay(1000) 83 | val nowTotalRxBytes = TrafficStats.getTotalRxBytes() 84 | val nowTimeStamp = System.currentTimeMillis() 85 | val speed = (nowTotalRxBytes - lastTotalRxBytes) / (nowTimeStamp - lastTimeStamp) * 1000 86 | lastTimeStamp = nowTimeStamp 87 | lastTotalRxBytes = nowTotalRxBytes 88 | 89 | netSpeed = speed 90 | } 91 | } 92 | 93 | return netSpeed 94 | } 95 | 96 | @Preview 97 | @Composable 98 | private fun LeanbackPanelPlayerInfoPreview() { 99 | LeanbackTheme { 100 | LeanbackPanelPlayerInfo( 101 | metadataProvider = { 102 | LeanbackVideoPlayer.Metadata( 103 | videoWidth = 1920, 104 | videoHeight = 1080, 105 | ) 106 | }, 107 | ) 108 | } 109 | } 110 | 111 | @Preview 112 | @Composable 113 | private fun LeanbackPanelPlayerInfoNetSpeedPreview() { 114 | LeanbackTheme { 115 | Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { 116 | PanelPlayerInfoNetSpeed() 117 | PanelPlayerInfoNetSpeed(netSpeed = 54321) 118 | PanelPlayerInfoNetSpeed(netSpeed = 1222 * 1222) 119 | } 120 | } 121 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/SettingsCategories.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.BugReport 5 | import androidx.compose.material.icons.filled.DisplaySettings 6 | import androidx.compose.material.icons.filled.FormatListNumbered 7 | import androidx.compose.material.icons.filled.Http 8 | import androidx.compose.material.icons.filled.Info 9 | import androidx.compose.material.icons.filled.LiveTv 10 | import androidx.compose.material.icons.filled.Menu 11 | import androidx.compose.material.icons.filled.MoreHoriz 12 | import androidx.compose.material.icons.filled.Settings 13 | import androidx.compose.material.icons.filled.SmartDisplay 14 | import androidx.compose.material.icons.filled.Star 15 | import androidx.compose.material.icons.filled.Update 16 | import androidx.compose.ui.graphics.vector.ImageVector 17 | 18 | enum class LeanbackSettingsCategories( 19 | val icon: ImageVector, 20 | val title: String 21 | ) { 22 | ABOUT(Icons.Default.Info, "关于"), 23 | APP(Icons.Default.Settings, "应用"), 24 | IPTV(Icons.Default.LiveTv, "直播源"), 25 | EPG(Icons.Default.Menu, "节目单"), 26 | UI(Icons.Default.DisplaySettings, "界面"), 27 | FAVORITE(Icons.Default.Star, "收藏"), 28 | UPDATE(Icons.Default.Update, "更新"), 29 | VIDEO_PLAYER(Icons.Default.SmartDisplay, "播放器"), 30 | HTTP(Icons.Default.Http, "网络"), 31 | DEBUG(Icons.Default.BugReport, "调试"), 32 | LOG(Icons.Default.FormatListNumbered, "日志"), 33 | MORE(Icons.Default.MoreHoriz, "更多设置"), 34 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/SettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.gestures.detectTapGestures 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.width 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.LaunchedEffect 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.focus.FocusRequester 20 | import androidx.compose.ui.focus.focusRequester 21 | import androidx.compose.ui.input.pointer.pointerInput 22 | import androidx.compose.ui.tooling.preview.Preview 23 | import androidx.compose.ui.unit.dp 24 | import top.yogiczy.mytv.ui.rememberLeanbackChildPadding 25 | import top.yogiczy.mytv.ui.screens.leanback.settings.components.LeanbackSettingsCategoryContent 26 | import top.yogiczy.mytv.ui.screens.leanback.settings.components.LeanbackSettingsCategoryList 27 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 28 | 29 | @Composable 30 | fun LeanbackSettingsScreen( 31 | modifier: Modifier = Modifier, 32 | ) { 33 | val childPadding = rememberLeanbackChildPadding() 34 | val focusRequester = remember { FocusRequester() } 35 | LaunchedEffect(Unit) { 36 | focusRequester.requestFocus() 37 | } 38 | 39 | var focusedCategory by remember { mutableStateOf(LeanbackSettingsCategories.entries.first()) } 40 | 41 | Box( 42 | modifier = modifier 43 | .fillMaxSize() 44 | .focusRequester(focusRequester) 45 | .background(MaterialTheme.colorScheme.surface) 46 | .padding( 47 | top = childPadding.top + 20.dp, 48 | bottom = childPadding.bottom, 49 | start = childPadding.start, 50 | end = childPadding.end, 51 | ) 52 | .pointerInput(Unit) { detectTapGestures(onTap = { }) }, 53 | ) { 54 | Row( 55 | horizontalArrangement = Arrangement.spacedBy(40.dp), 56 | ) { 57 | LeanbackSettingsCategoryList( 58 | modifier = Modifier.width(200.dp), 59 | focusedCategoryProvider = { focusedCategory }, 60 | onFocused = { focusedCategory = it }, 61 | ) 62 | 63 | LeanbackSettingsCategoryContent( 64 | focusedCategoryProvider = { focusedCategory }, 65 | ) 66 | } 67 | } 68 | } 69 | 70 | @Preview(device = "id:Android TV (720p)") 71 | @Composable 72 | private fun LeanbackSettingsScreenPreview() { 73 | LeanbackTheme { 74 | LeanbackSettingsScreen() 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryAbout.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageInfo 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.automirrored.filled.OpenInNew 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.runtime.setValue 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.platform.LocalContext 18 | import androidx.compose.ui.tooling.preview.Preview 19 | import androidx.compose.ui.unit.dp 20 | import androidx.tv.foundation.lazy.list.TvLazyColumn 21 | import androidx.tv.material3.Icon 22 | import top.yogiczy.mytv.data.utils.Constants 23 | import top.yogiczy.mytv.ui.screens.leanback.components.LeanbackQrcodeDialog 24 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 25 | 26 | @Composable 27 | fun LeanbackSettingsCategoryAbout( 28 | modifier: Modifier = Modifier, 29 | packageInfo: PackageInfo = rememberPackageInfo(), 30 | ) { 31 | TvLazyColumn( 32 | modifier = modifier, 33 | verticalArrangement = Arrangement.spacedBy(10.dp), 34 | contentPadding = PaddingValues(vertical = 10.dp), 35 | ) { 36 | item { 37 | LeanbackSettingsCategoryListItem( 38 | headlineContent = "应用名称", 39 | trailingContent = Constants.APP_TITLE, 40 | ) 41 | } 42 | 43 | item { 44 | LeanbackSettingsCategoryListItem( 45 | headlineContent = "应用版本", 46 | trailingContent = packageInfo.versionName, 47 | ) 48 | } 49 | 50 | item { 51 | var showQrDialog by remember { mutableStateOf(false) } 52 | 53 | LeanbackSettingsCategoryListItem( 54 | headlineContent = "代码仓库", 55 | trailingContent = { 56 | Row( 57 | horizontalArrangement = Arrangement.spacedBy(4.dp), 58 | verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, 59 | ) { 60 | androidx.tv.material3.Text(Constants.APP_REPO) 61 | 62 | Icon( 63 | Icons.AutoMirrored.Default.OpenInNew, 64 | contentDescription = null, 65 | modifier = Modifier.size(16.dp), 66 | ) 67 | } 68 | }, 69 | onSelected = { showQrDialog = true }, 70 | ) 71 | 72 | LeanbackQrcodeDialog( 73 | text = Constants.APP_REPO, 74 | description = "扫码前往代码仓库", 75 | showDialogProvider = { showQrDialog }, 76 | onDismissRequest = { showQrDialog = false }, 77 | ) 78 | } 79 | } 80 | } 81 | 82 | @Composable 83 | private fun rememberPackageInfo(context: Context = LocalContext.current): PackageInfo = 84 | context.packageManager.getPackageInfo(context.packageName, 0) 85 | 86 | @Preview 87 | @Composable 88 | private fun LeanbackSettingsAboutPreview() { 89 | LeanbackTheme { 90 | LeanbackSettingsCategoryAbout( 91 | packageInfo = PackageInfo().apply { 92 | versionName = "1.0.0" 93 | } 94 | ) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryApp.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import android.widget.Toast 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.Switch 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.compose.ui.tooling.preview.Preview 12 | import androidx.compose.ui.unit.dp 13 | import androidx.lifecycle.viewmodel.compose.viewModel 14 | import androidx.tv.foundation.lazy.list.TvLazyColumn 15 | import top.yogiczy.mytv.ui.screens.leanback.settings.LeanbackSettingsViewModel 16 | import top.yogiczy.mytv.ui.screens.leanback.update.LeanBackUpdateViewModel 17 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 18 | import top.yogiczy.mytv.ui.utils.SP 19 | 20 | @Composable 21 | fun LeanbackSettingsCategoryApp( 22 | modifier: Modifier = Modifier, 23 | settingsViewModel: LeanbackSettingsViewModel = viewModel(), 24 | updateViewModel: LeanBackUpdateViewModel = viewModel(), 25 | ) { 26 | 27 | TvLazyColumn( 28 | modifier = modifier, 29 | verticalArrangement = Arrangement.spacedBy(10.dp), 30 | contentPadding = PaddingValues(vertical = 10.dp), 31 | ) { 32 | item { 33 | LeanbackSettingsCategoryListItem( 34 | headlineContent = "开机自启", 35 | supportingContent = "请确保当前设备支持该功能", 36 | trailingContent = { 37 | Switch(checked = settingsViewModel.appBootLaunch, onCheckedChange = null) 38 | }, 39 | onSelected = { 40 | settingsViewModel.appBootLaunch = !settingsViewModel.appBootLaunch 41 | }, 42 | ) 43 | } 44 | 45 | item { 46 | val context = LocalContext.current 47 | 48 | LeanbackSettingsCategoryListItem( 49 | headlineContent = "显示模式", 50 | supportingContent = "短按切换应用显示模式", 51 | trailingContent = when (settingsViewModel.appDeviceDisplayType) { 52 | SP.AppDeviceDisplayType.LEANBACK -> "TV" 53 | SP.AppDeviceDisplayType.PAD -> "Pad" 54 | SP.AppDeviceDisplayType.MOBILE -> "手机" 55 | }, 56 | onSelected = { 57 | Toast.makeText(context, "暂未开放", Toast.LENGTH_SHORT).show() 58 | // settingsViewModel.appDeviceDisplayType = SP.AppDeviceDisplayType.entries[ 59 | // (settingsViewModel.appDeviceDisplayType.ordinal + 1) % SP.AppDeviceDisplayType.entries.size 60 | // ] 61 | }, 62 | ) 63 | } 64 | 65 | item { 66 | LeanbackSettingsCategoryListItem( 67 | headlineContent = "应用更新", 68 | supportingContent = "最新版本:v${updateViewModel.latestRelease.version}", 69 | trailingContent = if (updateViewModel.isUpdateAvailable) "发现新版本" else "无更新", 70 | onSelected = { 71 | if (updateViewModel.isUpdateAvailable) 72 | updateViewModel.showDialog = true 73 | }, 74 | ) 75 | } 76 | } 77 | } 78 | 79 | @Preview 80 | @Composable 81 | private fun LeanbackSettingsCategoryAppPreview() { 82 | SP.init(LocalContext.current) 83 | LeanbackTheme { 84 | LeanbackSettingsCategoryApp( 85 | modifier = Modifier.padding(20.dp), 86 | settingsViewModel = LeanbackSettingsViewModel(), 87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryContent.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | import top.yogiczy.mytv.ui.screens.leanback.settings.LeanbackSettingsCategories 11 | import top.yogiczy.mytv.utils.Logger 12 | 13 | @Composable 14 | fun LeanbackSettingsCategoryContent( 15 | modifier: Modifier = Modifier, 16 | focusedCategoryProvider: () -> LeanbackSettingsCategories = { LeanbackSettingsCategories.entries.first() }, 17 | ) { 18 | val focusedCategory = focusedCategoryProvider() 19 | 20 | Column( 21 | modifier = modifier, 22 | verticalArrangement = Arrangement.spacedBy(16.dp), 23 | ) { 24 | Text(text = focusedCategory.title, style = MaterialTheme.typography.headlineSmall) 25 | 26 | when (focusedCategory) { 27 | LeanbackSettingsCategories.ABOUT -> LeanbackSettingsCategoryAbout() 28 | LeanbackSettingsCategories.APP -> LeanbackSettingsCategoryApp() 29 | LeanbackSettingsCategories.IPTV -> LeanbackSettingsCategoryIptv() 30 | LeanbackSettingsCategories.EPG -> LeanbackSettingsCategoryEpg() 31 | LeanbackSettingsCategories.UI -> LeanbackSettingsCategoryUI() 32 | LeanbackSettingsCategories.FAVORITE -> LeanbackSettingsCategoryFavorite() 33 | LeanbackSettingsCategories.UPDATE -> LeanbackSettingsCategoryUpdate() 34 | LeanbackSettingsCategories.VIDEO_PLAYER -> LeanbackSettingsCategoryVideoPlayer() 35 | LeanbackSettingsCategories.HTTP -> LeanbackSettingsCategoryHttp() 36 | LeanbackSettingsCategories.DEBUG -> LeanbackSettingsCategoryDebug() 37 | LeanbackSettingsCategories.LOG -> LeanbackSettingsCategoryLog( 38 | history = Logger.history, 39 | ) 40 | LeanbackSettingsCategories.MORE -> LeanbackSettingsCategoryMore() 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryDebug.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.Switch 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import androidx.compose.ui.unit.dp 11 | import androidx.lifecycle.viewmodel.compose.viewModel 12 | import androidx.tv.foundation.lazy.list.TvLazyColumn 13 | import top.yogiczy.mytv.ui.screens.leanback.settings.LeanbackSettingsViewModel 14 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 15 | 16 | @Composable 17 | fun LeanbackSettingsCategoryDebug( 18 | modifier: Modifier = Modifier, 19 | settingsViewModel: LeanbackSettingsViewModel = viewModel(), 20 | ) { 21 | TvLazyColumn( 22 | modifier = modifier, 23 | verticalArrangement = Arrangement.spacedBy(10.dp), 24 | contentPadding = PaddingValues(vertical = 10.dp), 25 | ) { 26 | item { 27 | LeanbackSettingsCategoryListItem( 28 | headlineContent = "显示FPS", 29 | supportingContent = "在屏幕左上角显示fps和柱状图", 30 | trailingContent = { 31 | Switch(checked = settingsViewModel.debugShowFps, onCheckedChange = null) 32 | }, 33 | onSelected = { 34 | settingsViewModel.debugShowFps = !settingsViewModel.debugShowFps 35 | }, 36 | ) 37 | } 38 | 39 | item { 40 | LeanbackSettingsCategoryListItem( 41 | headlineContent = "显示播放器信息", 42 | supportingContent = "显示播放器详细信息(编码、解码器、采样率等)", 43 | trailingContent = { 44 | Switch( 45 | checked = settingsViewModel.debugShowVideoPlayerMetadata, 46 | onCheckedChange = null 47 | ) 48 | }, 49 | onSelected = { 50 | settingsViewModel.debugShowVideoPlayerMetadata = 51 | !settingsViewModel.debugShowVideoPlayerMetadata 52 | }, 53 | ) 54 | } 55 | } 56 | } 57 | 58 | @Preview 59 | @Composable 60 | private fun LeanbackSettingsCategoryDebugPreview() { 61 | LeanbackTheme { 62 | LeanbackSettingsCategoryDebug( 63 | modifier = Modifier.padding(20.dp) 64 | ) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryFavorite.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.Switch 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import androidx.compose.ui.unit.dp 11 | import androidx.lifecycle.viewmodel.compose.viewModel 12 | import androidx.tv.foundation.lazy.list.TvLazyColumn 13 | import top.yogiczy.mytv.ui.screens.leanback.settings.LeanbackSettingsViewModel 14 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 15 | 16 | @Composable 17 | fun LeanbackSettingsCategoryFavorite( 18 | modifier: Modifier = Modifier, 19 | settingsViewModel: LeanbackSettingsViewModel = viewModel(), 20 | ) { 21 | TvLazyColumn( 22 | modifier = modifier, 23 | verticalArrangement = Arrangement.spacedBy(10.dp), 24 | contentPadding = PaddingValues(vertical = 10.dp), 25 | ) { 26 | item { 27 | LeanbackSettingsCategoryListItem( 28 | headlineContent = "收藏启用", 29 | trailingContent = { 30 | Switch( 31 | checked = settingsViewModel.iptvChannelFavoriteEnable, 32 | onCheckedChange = null 33 | ) 34 | }, 35 | onSelected = { 36 | settingsViewModel.iptvChannelFavoriteEnable = 37 | !settingsViewModel.iptvChannelFavoriteEnable 38 | if (!settingsViewModel.iptvChannelFavoriteEnable) { 39 | settingsViewModel.iptvChannelFavoriteListVisible = false 40 | } 41 | }, 42 | ) 43 | } 44 | 45 | item { 46 | LeanbackSettingsCategoryListItem( 47 | headlineContent = "当前已收藏", 48 | supportingContent = "包括不存在直播源中的频道", 49 | trailingContent = "${settingsViewModel.iptvChannelFavoriteList.size}个频道", 50 | ) 51 | } 52 | 53 | item { 54 | LeanbackSettingsCategoryListItem( 55 | headlineContent = "清空全部收藏", 56 | supportingContent = "短按立即清空全部收藏", 57 | onSelected = { 58 | settingsViewModel.iptvChannelFavoriteList = emptySet() 59 | settingsViewModel.iptvChannelFavoriteListVisible = false 60 | } 61 | ) 62 | } 63 | } 64 | } 65 | 66 | @Preview 67 | @Composable 68 | private fun LeanbackSettingsCategoryFavoritePreview() { 69 | LeanbackTheme { 70 | LeanbackSettingsCategoryFavorite( 71 | modifier = Modifier.padding(20.dp), 72 | settingsViewModel = LeanbackSettingsViewModel().apply { 73 | iptvChannelFavoriteList = setOf("CCTV-1", "CCTV-2", "CCTV-3") 74 | } 75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryHttp.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import androidx.compose.ui.unit.dp 11 | import androidx.tv.foundation.lazy.list.TvLazyColumn 12 | import top.yogiczy.mytv.data.utils.Constants 13 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 14 | import top.yogiczy.mytv.ui.utils.SP 15 | import top.yogiczy.mytv.utils.humanizeMs 16 | 17 | @Composable 18 | fun LeanbackSettingsCategoryHttp( 19 | modifier: Modifier = Modifier, 20 | ) { 21 | TvLazyColumn( 22 | modifier = modifier, 23 | verticalArrangement = Arrangement.spacedBy(10.dp), 24 | contentPadding = PaddingValues(vertical = 10.dp), 25 | ) { 26 | item { 27 | LeanbackSettingsCategoryListItem( 28 | headlineContent = "HTTP请求重试次数", 29 | supportingContent = "影响直播源、节目单数据获取", 30 | trailingContent = Constants.HTTP_RETRY_COUNT.toString(), 31 | locK = true, 32 | ) 33 | } 34 | 35 | item { 36 | LeanbackSettingsCategoryListItem( 37 | headlineContent = "HTTP请求重试间隔时间", 38 | supportingContent = "影响直播源、节目单数据获取", 39 | trailingContent = Constants.HTTP_RETRY_INTERVAL.humanizeMs(), 40 | locK = true, 41 | ) 42 | } 43 | } 44 | } 45 | 46 | @Preview 47 | @Composable 48 | private fun LeanbackSettingsCategoryHttpPreview() { 49 | SP.init(LocalContext.current) 50 | LeanbackTheme { 51 | LeanbackSettingsCategoryHttp( 52 | modifier = Modifier.padding(20.dp), 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryListItem.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.automirrored.filled.OpenInNew 8 | import androidx.compose.material.icons.filled.Lock 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.surfaceColorAtElevation 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.runtime.setValue 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.focus.FocusRequester 18 | import androidx.compose.ui.focus.focusRequester 19 | import androidx.compose.ui.focus.onFocusChanged 20 | import androidx.compose.ui.unit.dp 21 | import androidx.tv.material3.Icon 22 | import androidx.tv.material3.ListItemDefaults 23 | import top.yogiczy.mytv.ui.screens.leanback.components.LeanbackQrcodeDialog 24 | import top.yogiczy.mytv.ui.utils.HttpServer 25 | import top.yogiczy.mytv.ui.utils.handleLeanbackKeyEvents 26 | 27 | @Composable 28 | fun LeanbackSettingsCategoryListItem( 29 | modifier: Modifier = Modifier, 30 | headlineContent: String, 31 | supportingContent: String? = null, 32 | trailingContent: @Composable () -> Unit = {}, 33 | onSelected: (() -> Unit)? = null, 34 | onLongSelected: () -> Unit = {}, 35 | locK: Boolean = false, 36 | remoteConfig: Boolean = false, 37 | ) { 38 | val focusRequester = remember { FocusRequester() } 39 | var isFocused by remember { mutableStateOf(false) } 40 | 41 | var showServerUrlDialog by remember { mutableStateOf(false) } 42 | 43 | androidx.tv.material3.ListItem( 44 | selected = false, 45 | onClick = { }, 46 | colors = ListItemDefaults.colors( 47 | containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), 48 | ), 49 | headlineContent = { 50 | androidx.tv.material3.Text(text = headlineContent) 51 | }, 52 | trailingContent = { 53 | Row( 54 | horizontalArrangement = Arrangement.spacedBy(4.dp), 55 | verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, 56 | ) { 57 | trailingContent() 58 | if (locK) { 59 | Icon( 60 | Icons.Default.Lock, 61 | contentDescription = null, 62 | modifier = Modifier.size(16.dp), 63 | ) 64 | } 65 | 66 | if (remoteConfig) { 67 | Icon( 68 | Icons.AutoMirrored.Default.OpenInNew, 69 | contentDescription = null, 70 | modifier = Modifier.size(16.dp), 71 | ) 72 | } 73 | } 74 | }, 75 | supportingContent = { supportingContent?.let { androidx.tv.material3.Text(it) } }, 76 | modifier = modifier 77 | .focusRequester(focusRequester) 78 | .onFocusChanged { isFocused = it.isFocused || it.hasFocus } 79 | .handleLeanbackKeyEvents( 80 | onSelect = { 81 | if (isFocused) { 82 | if (onSelected != null) onSelected() 83 | else if (remoteConfig) showServerUrlDialog = true 84 | } else focusRequester.requestFocus() 85 | }, 86 | onLongSelect = { 87 | if (isFocused) onLongSelected() 88 | else focusRequester.requestFocus() 89 | }, 90 | ), 91 | ) 92 | 93 | LeanbackQrcodeDialog( 94 | text = HttpServer.serverUrl, 95 | description = "扫码前往设置页面", 96 | showDialogProvider = { showServerUrlDialog }, 97 | onDismissRequest = { showServerUrlDialog = false }, 98 | ) 99 | } 100 | 101 | @Composable 102 | fun LeanbackSettingsCategoryListItem( 103 | modifier: Modifier = Modifier, 104 | headlineContent: String, 105 | supportingContent: String? = null, 106 | trailingContent: String, 107 | onSelected: () -> Unit = {}, 108 | onLongSelected: () -> Unit = {}, 109 | locK: Boolean = false, 110 | remoteConfig: Boolean = false, 111 | ) { 112 | LeanbackSettingsCategoryListItem( 113 | modifier = modifier, 114 | headlineContent = headlineContent, 115 | supportingContent = supportingContent, 116 | trailingContent = { androidx.tv.material3.Text(trailingContent) }, 117 | onSelected = onSelected, 118 | onLongSelected = onLongSelected, 119 | locK = locK, 120 | remoteConfig = remoteConfig, 121 | ) 122 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryLog.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.tooling.preview.Preview 9 | import androidx.compose.ui.unit.dp 10 | import androidx.tv.foundation.lazy.list.TvLazyColumn 11 | import androidx.tv.foundation.lazy.list.items 12 | import kotlinx.collections.immutable.persistentListOf 13 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 14 | import top.yogiczy.mytv.utils.Logger 15 | import java.text.SimpleDateFormat 16 | import java.util.Locale 17 | 18 | @Composable 19 | fun LeanbackSettingsCategoryLog( 20 | modifier: Modifier = Modifier, 21 | history: List = emptyList(), 22 | ) { 23 | val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) 24 | val historySorted = remember(history) { 25 | history.sortedByDescending { it.time } 26 | } 27 | 28 | TvLazyColumn( 29 | modifier = modifier, 30 | verticalArrangement = Arrangement.spacedBy(10.dp), 31 | contentPadding = PaddingValues(vertical = 10.dp), 32 | ) { 33 | items(historySorted) { 34 | LeanbackSettingsCategoryListItem( 35 | headlineContent = "${it.level.toString()[0]} ${it.tag}", 36 | supportingContent = it.message, 37 | trailingContent = timeFormat.format(it.time), 38 | ) 39 | } 40 | } 41 | } 42 | 43 | @Preview 44 | @Composable 45 | private fun LeanbackSettingsCategoryLogPreview() { 46 | LeanbackTheme { 47 | LeanbackSettingsCategoryLog( 48 | history = persistentListOf( 49 | Logger.HistoryItem( 50 | level = Logger.LevelType.ERROR, 51 | tag = "LeanbackSettingsCategoryLog", 52 | message = "This is a test message", 53 | time = System.currentTimeMillis(), 54 | ), 55 | ) 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryMore.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.width 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.LaunchedEffect 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.runtime.setValue 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.compose.ui.unit.dp 18 | import androidx.tv.foundation.lazy.list.TvLazyColumn 19 | import kotlinx.coroutines.delay 20 | import top.yogiczy.mytv.ui.screens.leanback.components.LeanbackQrcode 21 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 22 | import top.yogiczy.mytv.ui.utils.HttpServer 23 | 24 | @Composable 25 | fun LeanbackSettingsCategoryMore( 26 | modifier: Modifier = Modifier, 27 | serverUrl: String = HttpServer.serverUrl, 28 | ) { 29 | TvLazyColumn( 30 | modifier = modifier, 31 | verticalArrangement = Arrangement.spacedBy(10.dp), 32 | contentPadding = PaddingValues(vertical = 10.dp), 33 | ) { 34 | item { 35 | LeanbackSettingsCategoryListItem( 36 | headlineContent = "设置页面", 37 | trailingContent = serverUrl, 38 | ) 39 | } 40 | 41 | item { 42 | var show by remember { mutableStateOf(false) } 43 | 44 | LaunchedEffect(Unit) { 45 | delay(100) 46 | show = true 47 | } 48 | 49 | if (show) { 50 | Row( 51 | horizontalArrangement = Arrangement.End, 52 | modifier = Modifier.fillMaxWidth(), 53 | ) { 54 | LeanbackQrcode( 55 | modifier = Modifier 56 | .width(200.dp) 57 | .height(200.dp), 58 | text = serverUrl, 59 | ) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | 66 | @Preview 67 | @Composable 68 | private fun LeanbackSettingsMorePreview() { 69 | LeanbackTheme { 70 | LeanbackSettingsCategoryMore( 71 | serverUrl = "http://127.0.0.1:10481", 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryUpdate.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.Switch 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import androidx.compose.ui.unit.dp 11 | import androidx.lifecycle.viewmodel.compose.viewModel 12 | import androidx.tv.foundation.lazy.list.TvLazyColumn 13 | import top.yogiczy.mytv.ui.screens.leanback.settings.LeanbackSettingsViewModel 14 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 15 | 16 | @Composable 17 | fun LeanbackSettingsCategoryUpdate( 18 | modifier: Modifier = Modifier, 19 | settingsViewModel: LeanbackSettingsViewModel = viewModel(), 20 | ) { 21 | TvLazyColumn( 22 | modifier = modifier, 23 | verticalArrangement = Arrangement.spacedBy(10.dp), 24 | contentPadding = PaddingValues(vertical = 10.dp), 25 | ) { 26 | item { 27 | LeanbackSettingsCategoryListItem( 28 | headlineContent = "更新强提醒", 29 | supportingContent = if (settingsViewModel.updateForceRemind) "检测到新版本时会弹窗提醒" 30 | else "检测到新版本时仅在左上角提示", 31 | trailingContent = { 32 | Switch(checked = settingsViewModel.updateForceRemind, onCheckedChange = null) 33 | }, 34 | onSelected = { 35 | settingsViewModel.updateForceRemind = !settingsViewModel.updateForceRemind 36 | }, 37 | ) 38 | } 39 | } 40 | } 41 | 42 | @Preview 43 | @Composable 44 | private fun LeanbackSettingsCategoryUpdatePreview() { 45 | LeanbackTheme { 46 | LeanbackSettingsCategoryUpdate( 47 | modifier = Modifier.padding(20.dp), 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/settings/components/SettingsCategoryVideoPlayer.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.settings.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import androidx.compose.ui.unit.dp 11 | import androidx.lifecycle.viewmodel.compose.viewModel 12 | import androidx.tv.foundation.lazy.list.TvLazyColumn 13 | import top.yogiczy.mytv.ui.screens.leanback.settings.LeanbackSettingsViewModel 14 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 15 | import top.yogiczy.mytv.ui.utils.SP 16 | import top.yogiczy.mytv.utils.humanizeMs 17 | import kotlin.math.max 18 | 19 | @Composable 20 | fun LeanbackSettingsCategoryVideoPlayer( 21 | modifier: Modifier = Modifier, 22 | settingsViewModel: LeanbackSettingsViewModel = viewModel(), 23 | ) { 24 | TvLazyColumn( 25 | modifier = modifier, 26 | verticalArrangement = Arrangement.spacedBy(10.dp), 27 | contentPadding = PaddingValues(vertical = 10.dp), 28 | ) { 29 | item { 30 | LeanbackSettingsCategoryListItem( 31 | headlineContent = "全局画面比例", 32 | trailingContent = when (settingsViewModel.videoPlayerAspectRatio) { 33 | SP.VideoPlayerAspectRatio.ORIGINAL -> "原始" 34 | SP.VideoPlayerAspectRatio.SIXTEEN_NINE -> "16:9" 35 | SP.VideoPlayerAspectRatio.FOUR_THREE -> "4:3" 36 | SP.VideoPlayerAspectRatio.AUTO -> "自动拉伸" 37 | }, 38 | onSelected = { 39 | settingsViewModel.videoPlayerAspectRatio = 40 | SP.VideoPlayerAspectRatio.entries.let { 41 | it[(it.indexOf(settingsViewModel.videoPlayerAspectRatio) + 1) % it.size] 42 | } 43 | }, 44 | ) 45 | } 46 | 47 | 48 | item { 49 | val min = 1000 * 5L 50 | val max = 1000 * 30L 51 | val step = 1000 * 5L 52 | 53 | LeanbackSettingsCategoryListItem( 54 | headlineContent = "播放器加载超时", 55 | supportingContent = "影响超时换源、断线重连", 56 | trailingContent = settingsViewModel.videoPlayerLoadTimeout.humanizeMs(), 57 | onSelected = { 58 | settingsViewModel.videoPlayerLoadTimeout = 59 | max(min, (settingsViewModel.videoPlayerLoadTimeout + step) % (max + step)) 60 | }, 61 | ) 62 | } 63 | 64 | item { 65 | LeanbackSettingsCategoryListItem( 66 | headlineContent = "播放器自定义UA", 67 | supportingContent = settingsViewModel.videoPlayerUserAgent, 68 | remoteConfig = true, 69 | ) 70 | } 71 | } 72 | } 73 | 74 | @Preview 75 | @Composable 76 | private fun LeanbackSettingsCategoryHttpPreview() { 77 | SP.init(LocalContext.current) 78 | LeanbackTheme { 79 | LeanbackSettingsCategoryVideoPlayer( 80 | modifier = Modifier.padding(20.dp), 81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/toast/ToastScreen.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.toast 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.fadeIn 5 | import androidx.compose.animation.fadeOut 6 | import androidx.compose.animation.scaleIn 7 | import androidx.compose.animation.scaleOut 8 | import androidx.compose.foundation.background 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.size 14 | import androidx.compose.foundation.layout.sizeIn 15 | import androidx.compose.foundation.shape.CircleShape 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.outlined.Info 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.LaunchedEffect 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.graphics.vector.ImageVector 24 | import androidx.compose.ui.platform.LocalDensity 25 | import androidx.compose.ui.tooling.preview.Preview 26 | import androidx.compose.ui.unit.IntOffset 27 | import androidx.compose.ui.unit.dp 28 | import androidx.compose.ui.window.Popup 29 | import androidx.tv.material3.Icon 30 | import androidx.tv.material3.MaterialTheme 31 | import kotlinx.coroutines.delay 32 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 33 | 34 | @Composable 35 | fun LeanbackToastScreen( 36 | modifier: Modifier = Modifier, 37 | state: LeanbackToastState = rememberLeanbackToastState(), 38 | ) { 39 | Popup( 40 | alignment = Alignment.BottomCenter, 41 | offset = IntOffset(0, with(LocalDensity.current) { -28.dp.toPx().toInt() }), 42 | ) { 43 | AnimatedVisibility( 44 | visible = state.visible, 45 | enter = fadeIn() + scaleIn(), 46 | exit = fadeOut() + scaleOut(), 47 | modifier = modifier, 48 | ) { 49 | LeanbackToastItem(property = state.current) 50 | } 51 | } 52 | } 53 | 54 | @Composable 55 | fun LeanbackToastItem( 56 | modifier: Modifier = Modifier, 57 | property: LeanbackToastProperty = LeanbackToastProperty(), 58 | ) { 59 | Box( 60 | modifier = modifier 61 | .sizeIn(maxWidth = 556.dp) 62 | .background(MaterialTheme.colorScheme.inverseSurface, MaterialTheme.shapes.medium) 63 | .padding(horizontal = 16.dp, vertical = 12.dp), 64 | ) { 65 | Row( 66 | horizontalArrangement = Arrangement.spacedBy(8.dp), 67 | verticalAlignment = Alignment.CenterVertically, 68 | ) { 69 | LeanbackToastContentIcon( 70 | showIcon = true, 71 | icon = Icons.Outlined.Info, 72 | iconColor = MaterialTheme.colorScheme.inverseOnSurface, 73 | iconContainerColors = MaterialTheme.colorScheme.onSurfaceVariant, 74 | ) 75 | 76 | androidx.tv.material3.Text( 77 | property.message, 78 | color = MaterialTheme.colorScheme.inverseOnSurface 79 | ) 80 | } 81 | } 82 | } 83 | 84 | @Composable 85 | private fun LeanbackToastContentIcon( 86 | modifier: Modifier = Modifier, 87 | showIcon: Boolean, 88 | icon: ImageVector, 89 | iconColor: Color, 90 | iconContainerColors: Color, 91 | ) { 92 | if (showIcon) { 93 | Box( 94 | modifier = modifier 95 | .background(iconContainerColors, CircleShape) 96 | .padding(8.dp), 97 | ) { 98 | Icon( 99 | imageVector = icon, 100 | contentDescription = null, 101 | modifier = Modifier.size(16.dp), 102 | tint = iconColor, 103 | ) 104 | } 105 | } 106 | } 107 | 108 | @Preview(device = "id:Android TV (720p)", showBackground = true) 109 | @Composable 110 | private fun LeanbackToastScreenAnimationPreview() { 111 | LeanbackTheme { 112 | val state = rememberLeanbackToastState() 113 | 114 | LaunchedEffect(Unit) { 115 | while (true) { 116 | state.showToast("新版本: v1.2.2") 117 | delay(1000) 118 | state.showToast("新版本: v9.9.9") 119 | delay(5000) 120 | } 121 | } 122 | 123 | LeanbackToastScreen(state = state) 124 | } 125 | } 126 | 127 | @Preview(showBackground = true) 128 | @Composable 129 | private fun LeanbackToastScreenPreview() { 130 | LeanbackTheme { 131 | LeanbackToastItem( 132 | modifier = Modifier.padding(16.dp), 133 | property = LeanbackToastProperty(message = "新版本: v1.2.2"), 134 | ) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/toast/ToastState.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.toast 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.Stable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.runtime.rememberCoroutineScope 10 | import androidx.compose.runtime.setValue 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.FlowPreview 13 | import kotlinx.coroutines.channels.Channel 14 | import kotlinx.coroutines.delay 15 | import kotlinx.coroutines.flow.consumeAsFlow 16 | import kotlinx.coroutines.flow.debounce 17 | import kotlinx.coroutines.launch 18 | import top.yogiczy.mytv.ui.screens.leanback.toast.LeanbackToastProperty.Companion.toMs 19 | import java.util.UUID 20 | 21 | @Stable 22 | class LeanbackToastState(private val coroutineScope: CoroutineScope) { 23 | private var _visible by mutableStateOf(false) 24 | val visible get() = _visible 25 | 26 | private var _current by mutableStateOf(LeanbackToastProperty()) 27 | val current get() = _current 28 | 29 | private fun showToast(toast: LeanbackToastProperty) { 30 | coroutineScope.launch { 31 | if (_visible && _current.id != toast.id) { 32 | _visible = false 33 | delay(300) 34 | } 35 | 36 | _current = toast 37 | _visible = true 38 | channel.trySend(toast.duration.toMs()) 39 | } 40 | } 41 | 42 | fun showToast( 43 | message: String, 44 | duration: LeanbackToastProperty.Duration = LeanbackToastProperty.Duration.Default, 45 | id: String = UUID.randomUUID().toString(), 46 | ) { 47 | showToast(LeanbackToastProperty(message = message, duration = duration, id = id)) 48 | } 49 | 50 | private val channel = Channel(Channel.CONFLATED) 51 | 52 | @OptIn(FlowPreview::class) 53 | suspend fun observe() { 54 | channel.consumeAsFlow().debounce { it.toLong() }.collect { _visible = false } 55 | } 56 | 57 | companion object { 58 | // TODO 这种方法可能违反了 Compose 的规则 59 | lateinit var I: LeanbackToastState 60 | } 61 | } 62 | 63 | @Composable 64 | fun rememberLeanbackToastState(): LeanbackToastState { 65 | val coroutineScope = rememberCoroutineScope() 66 | 67 | return remember { 68 | LeanbackToastState(coroutineScope) 69 | }.also { 70 | LeanbackToastState.I = it 71 | LaunchedEffect(it) { it.observe() } 72 | } 73 | } 74 | 75 | data class LeanbackToastProperty( 76 | val message: String = "", 77 | val duration: Duration = Duration.Default, 78 | val id: String = UUID.randomUUID().toString(), 79 | ) { 80 | sealed interface Duration { 81 | data object Default : Duration 82 | data class Custom(val duration: Int) : Duration 83 | } 84 | 85 | companion object { 86 | fun Duration.toMs(): Int = when (val it = this) { 87 | is Duration.Default -> 2300 88 | is Duration.Custom -> it.duration 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/update/UpdateScreen.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.update 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.pm.PackageInfo 6 | import android.os.Build 7 | import android.provider.Settings 8 | import androidx.activity.compose.rememberLauncherForActivityResult 9 | import androidx.activity.result.contract.ActivityResultContracts 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.LaunchedEffect 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.rememberCoroutineScope 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.platform.LocalContext 16 | import androidx.lifecycle.viewmodel.compose.viewModel 17 | import kotlinx.coroutines.Dispatchers 18 | import kotlinx.coroutines.delay 19 | import kotlinx.coroutines.launch 20 | import top.yogiczy.mytv.AppGlobal 21 | import top.yogiczy.mytv.ui.screens.leanback.settings.LeanbackSettingsViewModel 22 | import top.yogiczy.mytv.ui.screens.leanback.toast.LeanbackToastState 23 | import top.yogiczy.mytv.ui.screens.leanback.update.components.LeanbackUpdateDialog 24 | import top.yogiczy.mytv.utils.ApkInstaller 25 | import java.io.File 26 | 27 | @Composable 28 | fun LeanbackUpdateScreen( 29 | modifier: Modifier = Modifier, 30 | settingsViewModel: LeanbackSettingsViewModel = viewModel(), 31 | updateViewModel: LeanBackUpdateViewModel = viewModel(), 32 | ) { 33 | val context = LocalContext.current 34 | val coroutineScope = rememberCoroutineScope() 35 | val packageInfo = rememberPackageInfo() 36 | val latestFile = remember { File(AppGlobal.cacheDir, "latest.apk") } 37 | 38 | LaunchedEffect(Unit) { 39 | delay(3000) 40 | updateViewModel.checkUpdate(packageInfo.versionName) 41 | 42 | val latestRelease = updateViewModel.latestRelease 43 | if ( 44 | updateViewModel.isUpdateAvailable && 45 | latestRelease.version != settingsViewModel.appLastLatestVersion 46 | ) { 47 | settingsViewModel.appLastLatestVersion = latestRelease.version 48 | 49 | if (settingsViewModel.updateForceRemind) { 50 | updateViewModel.showDialog = true 51 | } else { 52 | LeanbackToastState.I.showToast("新版本: v${latestRelease.version}") 53 | } 54 | } 55 | } 56 | 57 | val launcher = 58 | rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { 59 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 60 | if (context.packageManager.canRequestPackageInstalls()) { 61 | ApkInstaller.installApk(context, latestFile.path) 62 | } else { 63 | LeanbackToastState.I.showToast("未授予安装权限") 64 | } 65 | } 66 | } 67 | 68 | LaunchedEffect(updateViewModel.updateDownloaded) { 69 | if (!updateViewModel.updateDownloaded) return@LaunchedEffect 70 | 71 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { 72 | ApkInstaller.installApk(context, latestFile.path) 73 | } else { 74 | if (context.packageManager.canRequestPackageInstalls()) { 75 | ApkInstaller.installApk(context, latestFile.path) 76 | } else { 77 | val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) 78 | launcher.launch(intent) 79 | } 80 | } 81 | } 82 | 83 | LeanbackUpdateDialog( 84 | modifier = modifier, 85 | showDialogProvider = { updateViewModel.showDialog }, 86 | onDismissRequest = { updateViewModel.showDialog = false }, 87 | releaseProvider = { updateViewModel.latestRelease }, 88 | onUpdateAndInstall = { 89 | updateViewModel.showDialog = false 90 | coroutineScope.launch(Dispatchers.IO) { 91 | updateViewModel.downloadAndUpdate(latestFile) 92 | } 93 | }, 94 | ) 95 | } 96 | 97 | @Composable 98 | private fun rememberPackageInfo(context: Context = LocalContext.current): PackageInfo = 99 | context.packageManager.getPackageInfo(context.packageName, 0) 100 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/update/UpdateViewModel.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.update 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import top.yogiczy.mytv.data.entities.GitRelease 8 | import top.yogiczy.mytv.data.repositories.git.GitRepository 9 | import top.yogiczy.mytv.data.utils.Constants 10 | import top.yogiczy.mytv.ui.screens.leanback.toast.LeanbackToastProperty 11 | import top.yogiczy.mytv.ui.screens.leanback.toast.LeanbackToastState 12 | import top.yogiczy.mytv.utils.Downloader 13 | import top.yogiczy.mytv.utils.Logger 14 | import top.yogiczy.mytv.utils.compareVersion 15 | import java.io.File 16 | 17 | class LeanBackUpdateViewModel : ViewModel() { 18 | private val log = Logger.create(javaClass.simpleName) 19 | 20 | private var _isChecking = false 21 | private var _isUpdating = false 22 | 23 | private var _isUpdateAvailable by mutableStateOf(false) 24 | val isUpdateAvailable get() = _isUpdateAvailable 25 | 26 | private var _updateDownloaded by mutableStateOf(false) 27 | val updateDownloaded get() = _updateDownloaded 28 | 29 | private var _latestRelease by mutableStateOf(GitRelease()) 30 | val latestRelease get() = _latestRelease 31 | 32 | var showDialog by mutableStateOf(false) 33 | 34 | suspend fun checkUpdate(currentVersion: String) { 35 | if (_isChecking) return 36 | if (_isUpdateAvailable) return 37 | 38 | try { 39 | _isChecking = true 40 | _latestRelease = GitRepository().latestRelease(Constants.GIT_RELEASE_LATEST_URL) 41 | _isUpdateAvailable = _latestRelease.version.compareVersion(currentVersion) > 0 42 | } catch (e: Exception) { 43 | log.e("检查更新失败", e) 44 | } finally { 45 | _isChecking = false 46 | } 47 | } 48 | 49 | suspend fun downloadAndUpdate(latestFile: File) { 50 | if (!_isUpdateAvailable) return 51 | if (_isUpdating) return 52 | 53 | _isUpdating = true 54 | _updateDownloaded = false 55 | LeanbackToastState.I.showToast( 56 | "开始下载更新", 57 | LeanbackToastProperty.Duration.Custom(10_000), 58 | ) 59 | 60 | try { 61 | Downloader.downloadTo(_latestRelease.downloadUrl, latestFile.path) { 62 | LeanbackToastState.I.showToast( 63 | "正在下载更新: $it%", 64 | LeanbackToastProperty.Duration.Custom(10_000), 65 | "downloadProcess" 66 | ) 67 | } 68 | 69 | _updateDownloaded = true 70 | LeanbackToastState.I.showToast("下载更新成功") 71 | } catch (ex: Exception) { 72 | LeanbackToastState.I.showToast("下载更新失败") 73 | } finally { 74 | _isUpdating = false 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/update/components/UpdateDialog.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.update.components 2 | 3 | import androidx.compose.foundation.lazy.LazyColumn 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.LaunchedEffect 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.focus.FocusRequester 11 | import androidx.compose.ui.focus.focusRequester 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import androidx.compose.ui.window.DialogProperties 14 | import top.yogiczy.mytv.data.entities.GitRelease 15 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 16 | import top.yogiczy.mytv.ui.utils.handleLeanbackKeyEvents 17 | 18 | @Composable 19 | fun LeanbackUpdateDialog( 20 | modifier: Modifier = Modifier, 21 | showDialogProvider: () -> Boolean = { false }, 22 | onDismissRequest: () -> Unit = {}, 23 | releaseProvider: () -> GitRelease = { GitRelease() }, 24 | onUpdateAndInstall: () -> Unit = {}, 25 | ) { 26 | if (showDialogProvider()) { 27 | val release = releaseProvider() 28 | val focusRequester = remember { FocusRequester() } 29 | 30 | LaunchedEffect(Unit) { 31 | focusRequester.requestFocus() 32 | } 33 | 34 | AlertDialog( 35 | properties = DialogProperties(usePlatformDefaultWidth = false), 36 | modifier = modifier, 37 | onDismissRequest = onDismissRequest, 38 | confirmButton = { 39 | androidx.tv.material3.Button( 40 | onClick = {}, 41 | modifier = Modifier 42 | .focusRequester(focusRequester) 43 | .handleLeanbackKeyEvents( 44 | onSelect = onUpdateAndInstall, 45 | ), 46 | ) { 47 | androidx.tv.material3.Text(text = "立即更新") 48 | } 49 | }, 50 | dismissButton = { 51 | androidx.tv.material3.Button( 52 | onClick = {}, 53 | modifier = Modifier.handleLeanbackKeyEvents( 54 | onSelect = onDismissRequest, 55 | ), 56 | ) { 57 | androidx.tv.material3.Text(text = "忽略") 58 | } 59 | }, 60 | title = { 61 | Text(text = "新版本:v${release.version}") 62 | }, 63 | text = { 64 | LazyColumn { 65 | item { 66 | Text(text = release.description) 67 | } 68 | } 69 | } 70 | ) 71 | } 72 | } 73 | 74 | @Preview(device = "id:Android TV (720p)") 75 | @Composable 76 | private fun LeanbackUpdateDialogPreview() { 77 | LeanbackTheme { 78 | LeanbackUpdateDialog( 79 | showDialogProvider = { true }, 80 | releaseProvider = { 81 | GitRelease( 82 | version = "1.0.0", 83 | description = "版本更新日志".repeat(100), 84 | ) 85 | } 86 | ) 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/video/VideoPlayerErrorScreen.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.video 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.LocalContentColor 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import androidx.compose.ui.unit.dp 16 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 17 | 18 | @Composable 19 | fun LeanbackVideoPlayerErrorScreen( 20 | modifier: Modifier = Modifier, 21 | errorProvider: () -> String? = { null }, 22 | ) { 23 | Box(modifier = modifier.fillMaxSize()) { 24 | val error = errorProvider() 25 | if (error != null) { 26 | Column( 27 | modifier = Modifier 28 | .align(Alignment.Center) 29 | .background( 30 | color = MaterialTheme.colorScheme.background.copy(alpha = 0.8f), 31 | shape = MaterialTheme.shapes.medium, 32 | ) 33 | .padding(horizontal = 20.dp, vertical = 10.dp), 34 | horizontalAlignment = Alignment.CenterHorizontally, 35 | ) { 36 | Text( 37 | text = "播放失败", 38 | style = MaterialTheme.typography.headlineSmall, 39 | color = MaterialTheme.colorScheme.error, 40 | ) 41 | 42 | Text( 43 | text = error, 44 | style = MaterialTheme.typography.bodySmall, 45 | color = LocalContentColor.current.copy(alpha = 0.8f), 46 | ) 47 | } 48 | } 49 | } 50 | } 51 | 52 | @Preview(device = "id:Android TV (720p)") 53 | @Composable 54 | private fun LeanbackVideoPlayerErrorScreenPreview() { 55 | LeanbackTheme { 56 | LeanbackVideoPlayerErrorScreen( 57 | errorProvider = { "ERROR_CODE_BEHIND_LIVE_WINDOW" } 58 | ) 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/video/VideoPlayerState.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.video 2 | 3 | import android.view.SurfaceView 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.DisposableEffect 6 | import androidx.compose.runtime.Stable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableFloatStateOf 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.rememberCoroutineScope 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.platform.LocalLifecycleOwner 15 | import androidx.lifecycle.Lifecycle 16 | import androidx.lifecycle.LifecycleEventObserver 17 | import top.yogiczy.mytv.ui.screens.leanback.video.player.LeanbackMedia3VideoPlayer 18 | import top.yogiczy.mytv.ui.screens.leanback.video.player.LeanbackVideoPlayer 19 | 20 | /** 21 | * 播放器状态 22 | */ 23 | @Stable 24 | class LeanbackVideoPlayerState( 25 | private val instance: LeanbackVideoPlayer, 26 | private val defaultAspectRatioProvider: () -> Float? = { null }, 27 | ) { 28 | /** 视频宽高比 */ 29 | var aspectRatio by mutableFloatStateOf(16f / 9f) 30 | 31 | /** 错误 */ 32 | var error by mutableStateOf(null) 33 | 34 | /** 元数据 */ 35 | var metadata by mutableStateOf(LeanbackVideoPlayer.Metadata()) 36 | 37 | fun prepare(url: String) { 38 | error = null 39 | instance.prepare(url) 40 | } 41 | 42 | fun play() { 43 | instance.play() 44 | } 45 | 46 | fun pause() { 47 | instance.pause() 48 | } 49 | 50 | fun setVideoSurfaceView(surfaceView: SurfaceView) { 51 | instance.setVideoSurfaceView(surfaceView) 52 | } 53 | 54 | private val onReadyListeners = mutableListOf<() -> Unit>() 55 | private val onErrorListeners = mutableListOf<() -> Unit>() 56 | private val onCutoffListeners = mutableListOf<() -> Unit>() 57 | 58 | fun onReady(listener: () -> Unit) { 59 | onReadyListeners.add(listener) 60 | } 61 | 62 | fun onError(listener: () -> Unit) { 63 | onErrorListeners.add(listener) 64 | } 65 | 66 | fun onCutoff(listener: () -> Unit) { 67 | onCutoffListeners.add(listener) 68 | } 69 | 70 | fun initialize() { 71 | instance.initialize() 72 | instance.onResolution { width, height -> 73 | val defaultAspectRatio = defaultAspectRatioProvider() 74 | 75 | if (defaultAspectRatio == null) { 76 | if (width > 0 && height > 0) aspectRatio = width.toFloat() / height 77 | } else { 78 | aspectRatio = defaultAspectRatio 79 | } 80 | } 81 | instance.onError { ex -> 82 | error = if (ex != null) "${ex.errorCodeName}(${ex.errorCode})" 83 | else null 84 | 85 | if (error != null) onErrorListeners.forEach { it.invoke() } 86 | 87 | } 88 | instance.onReady { onReadyListeners.forEach { it.invoke() } } 89 | instance.onBuffering { if (it) error = null } 90 | instance.onPrepared { } 91 | instance.onMetadata { metadata = it } 92 | instance.onCutoff { onCutoffListeners.forEach { it.invoke() } } 93 | } 94 | 95 | fun release() { 96 | onReadyListeners.clear() 97 | onErrorListeners.clear() 98 | instance.release() 99 | } 100 | } 101 | 102 | @Composable 103 | fun rememberLeanbackVideoPlayerState( 104 | defaultAspectRatioProvider: () -> Float? = { null }, 105 | ): LeanbackVideoPlayerState { 106 | val context = LocalContext.current 107 | val lifecycleOwner = LocalLifecycleOwner.current 108 | val coroutineScope = rememberCoroutineScope() 109 | val state = remember { 110 | LeanbackVideoPlayerState( 111 | LeanbackMedia3VideoPlayer(context, coroutineScope), 112 | defaultAspectRatioProvider = defaultAspectRatioProvider, 113 | ) 114 | } 115 | 116 | DisposableEffect(Unit) { 117 | state.initialize() 118 | 119 | onDispose { 120 | state.release() 121 | } 122 | } 123 | 124 | DisposableEffect(lifecycleOwner) { 125 | val observer = LifecycleEventObserver { _, event -> 126 | if (event == Lifecycle.Event.ON_RESUME) { 127 | state.play() 128 | } else if (event == Lifecycle.Event.ON_STOP) { 129 | state.pause() 130 | } 131 | } 132 | 133 | lifecycleOwner.lifecycle.addObserver(observer) 134 | 135 | onDispose { 136 | lifecycleOwner.lifecycle.removeObserver(observer) 137 | } 138 | } 139 | 140 | return state 141 | } 142 | -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/video/VideoScreen.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.video 2 | 3 | import android.view.SurfaceView 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.aspectRatio 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.ui.viewinterop.AndroidView 13 | import top.yogiczy.mytv.ui.rememberLeanbackChildPadding 14 | import top.yogiczy.mytv.ui.screens.leanback.video.components.LeanbackVideoPlayerMetadata 15 | 16 | @Composable 17 | fun LeanbackVideoScreen( 18 | modifier: Modifier = Modifier, 19 | state: LeanbackVideoPlayerState = rememberLeanbackVideoPlayerState(), 20 | showMetadataProvider: () -> Boolean = { false }, 21 | ) { 22 | val context = LocalContext.current 23 | val childPadding = rememberLeanbackChildPadding() 24 | 25 | Box(modifier = modifier.fillMaxSize()) { 26 | AndroidView( 27 | modifier = Modifier 28 | .align(Alignment.Center) 29 | .aspectRatio(state.aspectRatio), 30 | factory = { 31 | // PlayerView 切换视频时黑屏闪烁,使用 SurfaceView 代替 32 | SurfaceView(context) 33 | }, 34 | update = { surfaceView -> 35 | state.setVideoSurfaceView(surfaceView) 36 | }, 37 | ) 38 | 39 | LeanbackVideoPlayerErrorScreen( 40 | errorProvider = { state.error }, 41 | ) 42 | 43 | if (showMetadataProvider()) { 44 | LeanbackVideoPlayerMetadata( 45 | modifier = Modifier.padding(start = childPadding.start, top = childPadding.top), 46 | metadata = state.metadata, 47 | ) 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/screens/leanback/video/components/VideoPlayerMetadata.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.screens.leanback.video.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.LocalContentColor 8 | import androidx.compose.material3.LocalTextStyle 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.CompositionLocalProvider 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import androidx.compose.ui.unit.dp 16 | import top.yogiczy.mytv.ui.screens.leanback.video.player.LeanbackVideoPlayer 17 | import top.yogiczy.mytv.ui.theme.LeanbackTheme 18 | 19 | @Composable 20 | fun LeanbackVideoPlayerMetadata( 21 | modifier: Modifier = Modifier, 22 | metadata: LeanbackVideoPlayer.Metadata, 23 | ) { 24 | CompositionLocalProvider( 25 | LocalTextStyle provides MaterialTheme.typography.labelMedium, 26 | LocalContentColor provides MaterialTheme.colorScheme.onBackground 27 | ) { 28 | Column( 29 | modifier = modifier 30 | .background( 31 | MaterialTheme.colorScheme.background.copy(alpha = 0.5f), 32 | MaterialTheme.shapes.extraSmall, 33 | ) 34 | .padding(horizontal = 8.dp, vertical = 4.dp), 35 | verticalArrangement = Arrangement.spacedBy(4.dp) 36 | ) { 37 | Column { 38 | Text("视频", style = MaterialTheme.typography.bodyMedium) 39 | Column(modifier = Modifier.padding(start = 10.dp)) { 40 | Text("编码: ${metadata.videoMimeType}") 41 | Text("解码器: ${metadata.videoDecoder}") 42 | Text("分辨率: ${metadata.videoWidth}x${metadata.videoHeight}") 43 | Text("色彩: ${metadata.videoColor}") 44 | Text("帧率: ${metadata.videoFrameRate}") 45 | Text("比特率: ${metadata.videoBitrate / 1024} kbps") 46 | } 47 | } 48 | 49 | Column { 50 | Text("音频", style = MaterialTheme.typography.bodyMedium) 51 | Column(modifier = Modifier.padding(start = 10.dp)) { 52 | Text("编码: ${metadata.audioMimeType}") 53 | Text("解码器: ${metadata.audioDecoder}") 54 | Text("声道数: ${metadata.audioChannels}") 55 | Text("采样率: ${metadata.audioSampleRate} Hz") 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | @Preview 63 | @Composable 64 | private fun LeanbackVideoMetadataPreview() { 65 | LeanbackTheme { 66 | LeanbackVideoPlayerMetadata( 67 | metadata = LeanbackVideoPlayer.Metadata( 68 | videoWidth = 1920, 69 | videoHeight = 1080, 70 | videoMimeType = "video/hevc", 71 | videoColor = "BT2020/Limited range/HLG/8/8", 72 | videoFrameRate = 25.0f, 73 | videoBitrate = 10605096, 74 | videoDecoder = "c2.goldfish.h264.decoder", 75 | 76 | audioMimeType = "audio/mp4a-latm", 77 | audioChannels = 2, 78 | audioSampleRate = 32000, 79 | audioDecoder = "c2.android.aac.decoder", 80 | ) 81 | ) 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/theme/LeanbackTheme.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.theme 2 | 3 | import androidx.compose.material3.LocalContentColor 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.darkColorScheme 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.CompositionLocalProvider 8 | import androidx.compose.ui.graphics.Color 9 | 10 | private val darkColorScheme 11 | @Composable get() = darkColorScheme( 12 | primary = Color(0xFFA8C8FF), 13 | onPrimary = Color(0xFF003062), 14 | primaryContainer = Color(0xFF00468A), 15 | onPrimaryContainer = Color(0xFFD6E3FF), 16 | secondary = Color(0xFFBDC7DC), 17 | onSecondary = Color(0xFF273141), 18 | secondaryContainer = Color(0xFF3E4758), 19 | onSecondaryContainer = Color(0xFFD9E3F8), 20 | tertiary = Color(0xFFDCBCE1), 21 | onTertiary = Color(0xFF3E2845), 22 | tertiaryContainer = Color(0xFF563E5C), 23 | onTertiaryContainer = Color(0xFFF9D8FE), 24 | background = Color(0xFF000000), 25 | onBackground = Color(0xFFFFFFFF), 26 | surface = Color(0xFF1A1C1E), 27 | onSurface = Color(0xFFE3E2E6), 28 | surfaceVariant = Color(0xFF43474E), 29 | onSurfaceVariant = Color(0xFFC4C6CF), 30 | error = Color(0xFFFFB4AB), 31 | onError = Color(0xFF690005), 32 | errorContainer = Color(0xFF93000A), 33 | onErrorContainer = Color(0xFFFFB4AB), 34 | ) 35 | 36 | @Composable 37 | fun LeanbackTheme( 38 | content: @Composable () -> Unit, 39 | ) { 40 | MaterialTheme( 41 | colorScheme = darkColorScheme, 42 | ) { 43 | androidx.tv.material3.MaterialTheme( 44 | androidx.tv.material3.darkColorScheme( 45 | primary = MaterialTheme.colorScheme.primary, 46 | onPrimary = MaterialTheme.colorScheme.onPrimary, 47 | primaryContainer = MaterialTheme.colorScheme.primaryContainer, 48 | onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer, 49 | secondary = MaterialTheme.colorScheme.secondary, 50 | onSecondary = MaterialTheme.colorScheme.onSecondary, 51 | secondaryContainer = MaterialTheme.colorScheme.secondaryContainer, 52 | onSecondaryContainer = MaterialTheme.colorScheme.onSecondaryContainer, 53 | tertiary = MaterialTheme.colorScheme.tertiary, 54 | onTertiary = MaterialTheme.colorScheme.onTertiary, 55 | tertiaryContainer = MaterialTheme.colorScheme.tertiaryContainer, 56 | onTertiaryContainer = MaterialTheme.colorScheme.onTertiaryContainer, 57 | background = MaterialTheme.colorScheme.background, 58 | onBackground = MaterialTheme.colorScheme.onBackground, 59 | surface = MaterialTheme.colorScheme.surface, 60 | onSurface = MaterialTheme.colorScheme.onSurface, 61 | surfaceVariant = MaterialTheme.colorScheme.surfaceVariant, 62 | onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant, 63 | error = MaterialTheme.colorScheme.error, 64 | onError = MaterialTheme.colorScheme.onError, 65 | errorContainer = MaterialTheme.colorScheme.errorContainer, 66 | onErrorContainer = MaterialTheme.colorScheme.onErrorContainer, 67 | ), 68 | ) { 69 | CompositionLocalProvider( 70 | LocalContentColor provides MaterialTheme.colorScheme.onBackground, 71 | androidx.tv.material3.LocalContentColor provides androidx.tv.material3.MaterialTheme.colorScheme.onBackground, 72 | ) { 73 | content() 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/theme/MobileTheme.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | 13 | private val DarkColorScheme = darkColorScheme() 14 | 15 | private val LightColorScheme = lightColorScheme() 16 | 17 | @Composable 18 | fun MobileTheme( 19 | darkTheme: Boolean = isSystemInDarkTheme(), 20 | dynamicColor: Boolean = true, 21 | content: @Composable () -> Unit 22 | ) { 23 | val colorScheme = when { 24 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 25 | val context = LocalContext.current 26 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 27 | } 28 | 29 | darkTheme -> DarkColorScheme 30 | else -> LightColorScheme 31 | } 32 | 33 | MaterialTheme( 34 | colorScheme = colorScheme, 35 | content = content 36 | ) 37 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/ui/theme/PadTheme.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | 13 | private val DarkColorScheme = darkColorScheme() 14 | 15 | private val LightColorScheme = lightColorScheme() 16 | 17 | @Composable 18 | fun PadTheme( 19 | darkTheme: Boolean = isSystemInDarkTheme(), 20 | dynamicColor: Boolean = true, 21 | content: @Composable () -> Unit 22 | ) { 23 | val colorScheme = when { 24 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 25 | val context = LocalContext.current 26 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 27 | } 28 | 29 | darkTheme -> DarkColorScheme 30 | else -> LightColorScheme 31 | } 32 | 33 | MaterialTheme( 34 | colorScheme = colorScheme, 35 | content = content 36 | ) 37 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/utils/ApkInstaller.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.utils 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Build 8 | import androidx.core.content.FileProvider 9 | import java.io.File 10 | 11 | object ApkInstaller { 12 | @SuppressLint("SetWorldReadable") 13 | fun installApk(context: Context, filePath: String) { 14 | val file = File(filePath) 15 | if (file.exists()) { 16 | val cacheDir = context.cacheDir 17 | val cachedApkFile = File(cacheDir, file.name).apply { 18 | writeBytes(file.readBytes()) 19 | // 解决Android6 无法解析安装包 20 | setReadable(true, false) 21 | } 22 | 23 | val uri = 24 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) FileProvider.getUriForFile( 25 | context, context.packageName + ".FileProvider", cachedApkFile 26 | ) 27 | else Uri.fromFile(cachedApkFile) 28 | 29 | val installIntent = Intent(Intent.ACTION_VIEW).apply { 30 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION 31 | setDataAndType(uri, "application/vnd.android.package-archive") 32 | } 33 | 34 | context.startActivity(installIntent) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/utils/Downloader.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.utils 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.launch 6 | import kotlinx.coroutines.withContext 7 | import okhttp3.Interceptor 8 | import okhttp3.OkHttpClient 9 | import okio.BufferedSource 10 | import okio.ForwardingSource 11 | import okio.buffer 12 | import java.io.File 13 | import java.io.FileOutputStream 14 | 15 | object Downloader : Loggable() { 16 | suspend fun downloadTo(url: String, filePath: String, onProgressCb: ((Int) -> Unit)?) = 17 | withContext(Dispatchers.IO) { 18 | log.d("下载文件: $url") 19 | val interceptor = Interceptor { chain -> 20 | val originalResponse = chain.proceed(chain.request()) 21 | originalResponse.newBuilder() 22 | .body(DownloadResponseBody(originalResponse, onProgressCb)).build() 23 | } 24 | 25 | val client = OkHttpClient.Builder().addNetworkInterceptor(interceptor).build() 26 | val request = okhttp3.Request.Builder().url(url).build() 27 | 28 | try { 29 | with(client.newCall(request).execute()) { 30 | if (!isSuccessful) { 31 | throw Exception("下载文件失败: $code") 32 | } 33 | 34 | val file = File(filePath) 35 | FileOutputStream(file).use { fos -> fos.write(body!!.bytes()) } 36 | } 37 | } catch (ex: Exception) { 38 | log.e("下载文件失败", ex) 39 | throw Exception("下载文件失败,请检查网络连接", ex) 40 | } 41 | } 42 | 43 | private class DownloadResponseBody( 44 | private val originalResponse: okhttp3.Response, 45 | private val onProgressCb: ((Int) -> Unit)?, 46 | ) : okhttp3.ResponseBody() { 47 | override fun contentLength() = originalResponse.body!!.contentLength() 48 | 49 | override fun contentType() = originalResponse.body?.contentType() 50 | 51 | override fun source(): BufferedSource { 52 | return object : ForwardingSource(originalResponse.body!!.source()) { 53 | var totalBytesRead = 0L 54 | 55 | override fun read(sink: okio.Buffer, byteCount: Long): Long { 56 | val bytesRead = super.read(sink, byteCount) 57 | totalBytesRead += if (bytesRead != -1L) bytesRead else 0 58 | val progress = (totalBytesRead * 100 / contentLength()).toInt() 59 | CoroutineScope(Dispatchers.IO).launch { 60 | onProgressCb?.invoke(progress) 61 | } 62 | return bytesRead 63 | } 64 | }.buffer() 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/utils/ExtensionUtils.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.utils 2 | 3 | import java.util.regex.Pattern 4 | 5 | fun Long.humanizeMs(): String { 6 | return when (this) { 7 | in 0..<60_000 -> "${this / 1000}秒" 8 | in 60_000..<3_600_000 -> "${this / 60_000}分钟" 9 | else -> "${this / 3_600_000}小时" 10 | } 11 | } 12 | 13 | fun String.isIPv6(): Boolean { 14 | val urlPattern = Pattern.compile( 15 | "^((http|https)://)?(\\[[0-9a-fA-F:]+])(:[0-9]+)?(/.*)?$" 16 | ) 17 | return urlPattern.matcher(this).matches() 18 | } 19 | 20 | fun String.compareVersion(version2: String): Int { 21 | fun parseVersion(version: String): Pair, String?> { 22 | val mainParts = version.split("-", limit = 2) 23 | val versionNumbers = mainParts[0].split(".").map { it.toInt() } 24 | val preReleaseLabel = if (mainParts.size > 1) mainParts[1] else null 25 | return versionNumbers to preReleaseLabel 26 | } 27 | 28 | fun comparePreRelease(label1: String?, label2: String?): Int { 29 | if (label1 == null && label2 == null) return 0 30 | if (label1 == null) return 1 // Non-pre-release version is greater 31 | if (label2 == null) return -1 // Non-pre-release version is greater 32 | 33 | // Compare pre-release labels lexicographically 34 | return label1.compareTo(label2) 35 | } 36 | 37 | val (v1, preRelease1) = parseVersion(this) 38 | val (v2, preRelease2) = parseVersion(version2) 39 | val maxLength = maxOf(v1.size, v2.size) 40 | 41 | for (i in 0 until maxLength) { 42 | val part1 = v1.getOrElse(i) { 0 } 43 | val part2 = v2.getOrElse(i) { 0 } 44 | if (part1 > part2) return 1 45 | if (part1 < part2) return -1 46 | } 47 | 48 | // If main version parts are equal, compare pre-release labels 49 | return comparePreRelease(preRelease1, preRelease2) 50 | } -------------------------------------------------------------------------------- /app/src/main/java/top/yogiczy/mytv/utils/Logger.kt: -------------------------------------------------------------------------------- 1 | package top.yogiczy.mytv.utils 2 | 3 | import android.util.Log 4 | import kotlinx.serialization.Serializable 5 | import top.yogiczy.mytv.data.utils.Constants 6 | 7 | /** 8 | * 日志工具类 9 | */ 10 | class Logger private constructor( 11 | private val tag: String 12 | ) { 13 | fun d(message: String, throwable: Throwable? = null) { 14 | Log.d(tag, message, throwable) 15 | // addHistoryItem(HistoryItem(LevelType.DEBUG, tag, message, throwable?.message)) 16 | } 17 | 18 | fun i(message: String, throwable: Throwable? = null) { 19 | Log.i(tag, message, throwable) 20 | addHistoryItem(HistoryItem(LevelType.INFO, tag, message, throwable?.message)) 21 | } 22 | 23 | fun w(message: String, throwable: Throwable? = null) { 24 | Log.w(tag, message, throwable) 25 | addHistoryItem(HistoryItem(LevelType.WARN, tag, message, throwable?.message)) 26 | } 27 | 28 | fun e(message: String, throwable: Throwable? = null) { 29 | Log.e(tag, message, throwable) 30 | addHistoryItem(HistoryItem(LevelType.ERROR, tag, message, throwable?.message)) 31 | } 32 | 33 | fun wtf(message: String, throwable: Throwable? = null) { 34 | Log.wtf(tag, message, throwable) 35 | addHistoryItem(HistoryItem(LevelType.ERROR, tag, message, throwable?.message)) 36 | } 37 | 38 | companion object { 39 | fun create(tag: String) = Logger(tag) 40 | 41 | private val _history = mutableListOf() 42 | val history: List 43 | get() = _history 44 | 45 | fun addHistoryItem(item: HistoryItem) { 46 | _history.add(item) 47 | if (_history.size > Constants.LOG_HISTORY_MAX_SIZE) _history.removeAt(0) 48 | } 49 | } 50 | 51 | enum class LevelType { 52 | DEBUG, INFO, WARN, ERROR 53 | } 54 | 55 | @Serializable 56 | data class HistoryItem( 57 | val level: LevelType, 58 | val tag: String, 59 | val message: String, 60 | val cause: String? = null, 61 | val time: Long = System.currentTimeMillis(), 62 | ) 63 | } 64 | 65 | /** 66 | * 注入日志 67 | */ 68 | abstract class Loggable(private val tag: String? = null) { 69 | protected val log: Logger 70 | get() = Logger.create(tag ?: javaClass.simpleName) 71 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/tv_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/drawable-xhdpi/tv_banner.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/raw/index_js.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/app/src/main/res/raw/index_js.js -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #1A1C1E 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 我的电视 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 |