├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | xmlns:android
21 |
22 | ^$
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | xmlns:.*
32 |
33 | ^$
34 |
35 |
36 | BY_NAME
37 |
38 |
39 |
40 |
41 |
42 |
43 | .*:id
44 |
45 | http://schemas.android.com/apk/res/android
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | .*:name
55 |
56 | http://schemas.android.com/apk/res/android
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | name
66 |
67 | ^$
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | style
77 |
78 | ^$
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | .*
88 |
89 | ^$
90 |
91 |
92 | BY_NAME
93 |
94 |
95 |
96 |
97 |
98 |
99 | .*
100 |
101 | http://schemas.android.com/apk/res/android
102 |
103 |
104 | ANDROID_ATTRIBUTE_ORDER
105 |
106 |
107 |
108 |
109 |
110 |
111 | .*
112 |
113 | .*
114 |
115 |
116 | BY_NAME
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/material_theme_project_new.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/app.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
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 | 
6 | 
7 | 
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 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/file_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/app/src/test/java/top/yogiczy/mytv/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package top.yogiczy.mytv
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | alias(libs.plugins.android.application) apply false
4 | alias(libs.plugins.jetbrains.kotlin.android) apply false
5 | alias(libs.plugins.compose.compiler) apply false
6 | alias(libs.plugins.kotlin.serialization) apply (false)
7 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.5.0"
3 | kotlin = "2.0.0"
4 | coreKtx = "1.13.1"
5 | junit = "4.13.2"
6 | junitVersion = "1.1.5"
7 | espressoCore = "3.5.1"
8 | kotlinxCollectionsImmutable = "0.3.7"
9 | lifecycleRuntimeKtx = "2.8.2"
10 | activityCompose = "1.9.0"
11 | composeBom = "2024.06.00"
12 | media3 = "1.3.1"
13 | okhttp = "4.12.0"
14 | qrose = "1.0.1"
15 | kotlinx-serialization = "1.7.0"
16 | androidasync = "3.1.0"
17 | tvFoundation = "1.0.0-alpha10"
18 | tvMaterial = "1.0.0-beta01"
19 |
20 | [libraries]
21 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
22 | junit = { group = "junit", name = "junit", version.ref = "junit" }
23 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
24 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
25 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
26 | androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
27 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
28 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
29 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
30 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
31 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
32 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
33 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
34 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
35 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
36 | androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
37 | androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
38 | androidx-media3-exoplayer-rtsp = { group = "androidx.media3", name = "media3-exoplayer-rtsp", version.ref = "media3" }
39 | kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
40 | kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
41 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
42 | qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" }
43 | androidasync = { module = "com.koushikdutta.async:androidasync", version.ref = "androidasync" }
44 | androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
45 | androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
46 | androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
47 |
48 | [plugins]
49 | android-application = { id = "com.android.application", version.ref = "agp" }
50 | jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
51 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
52 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
53 |
54 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu May 09 16:25:29 CST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/screenshots/Screenshot_panel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/screenshots/Screenshot_panel.png
--------------------------------------------------------------------------------
/screenshots/Screenshot_settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/screenshots/Screenshot_settings.png
--------------------------------------------------------------------------------
/screenshots/Screenshot_temp_panel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/screenshots/Screenshot_temp_panel.png
--------------------------------------------------------------------------------
/screenshots/mm_reward_qrcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yaoxieyoulei/mytv-android/575aa365c077ff44d01a6a6e3df02bf2270782ba/screenshots/mm_reward_qrcode.png
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "My TV"
23 | include(":app")
24 |
--------------------------------------------------------------------------------