├── .github ├── ISSUE_TEMPLATE │ ├── bug.yml │ └── fr.yml └── workflows │ └── build.yml ├── .gitignore ├── HISTORY.md ├── HOWTOs.md ├── Makefile ├── README.md ├── app ├── .gitignore ├── assets │ ├── common.txt │ ├── ipv6.txt │ └── mobile.txt ├── build.gradle.kts ├── libs │ └── lib-decoder-ffmpeg-release.aar ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── assets │ └── cacert.pem │ ├── java │ └── com │ │ └── lizongying │ │ └── mytv0 │ │ ├── BootReceiver.kt │ │ ├── ChannelFragment.kt │ │ ├── ConfirmationFragment.kt │ │ ├── ErrorFragment.kt │ │ ├── Ext.kt │ │ ├── GroupAdapter.kt │ │ ├── IgnoreSSLCertificate.kt │ │ ├── InfoFragment.kt │ │ ├── InitializerProvider.kt │ │ ├── ListAdapter.kt │ │ ├── LoadingFragment.kt │ │ ├── LocaleContextWrapper.kt │ │ ├── MainActivity.kt │ │ ├── MainViewModel.kt │ │ ├── MenuFragment.kt │ │ ├── ModalFragment.kt │ │ ├── MyTVApplication.kt │ │ ├── MyTVExceptionHandler.kt │ │ ├── PlayerFragment.kt │ │ ├── PortUtil.kt │ │ ├── QrCodeUtil.kt │ │ ├── SP.kt │ │ ├── SettingFragment.kt │ │ ├── SimpleServer.kt │ │ ├── TimeFragment.kt │ │ ├── UpdateManager.kt │ │ ├── Utils.kt │ │ ├── models │ │ ├── EPG.kt │ │ ├── EPGXmlParser.kt │ │ ├── Program.kt │ │ ├── SourceType.kt │ │ ├── TV.kt │ │ ├── TVGroupModel.kt │ │ ├── TVListModel.kt │ │ └── TVModel.kt │ │ └── requests │ │ ├── ConfigService.kt │ │ ├── DnsCache.kt │ │ ├── HttpClient.kt │ │ ├── InternalSSLSocketFactory.kt │ │ ├── InternalX509TrustManager.kt │ │ ├── ReleaseRequest.kt │ │ ├── ReleaseResponse.kt │ │ ├── ReleaseService.kt │ │ └── TimeResponse.kt │ └── res │ ├── color │ ├── switch_thumb_color.xml │ └── switch_track_color.xml │ ├── drawable │ ├── appreciate.jpg │ ├── banner0.png │ ├── ic_heart.xml │ ├── ic_heart_empty.xml │ ├── ic_heart_empty_light.xml │ ├── logo0.png │ ├── rounded_blue_left.xml │ ├── rounded_dark_bottom.xml │ ├── rounded_dark_left.xml │ ├── rounded_dark_right.xml │ ├── rounded_light_bottom.xml │ ├── rounded_white_left.xml │ ├── rounded_white_right.xml │ ├── rounded_white_top.xml │ └── sad_cloud.xml │ ├── layout │ ├── activity_main.xml │ ├── channel.xml │ ├── error.xml │ ├── group_item.xml │ ├── info.xml │ ├── list_item.xml │ ├── loading.xml │ ├── menu.xml │ ├── modal.xml │ ├── player.xml │ ├── setting.xml │ ├── show.xml │ └── time.xml │ ├── mipmap-hdpi │ └── ic_launcher.webp │ ├── mipmap-mdpi │ └── ic_launcher.webp │ ├── mipmap-xhdpi │ └── ic_launcher.webp │ ├── mipmap-xxhdpi │ └── ic_launcher.webp │ ├── mipmap-xxxhdpi │ └── ic_launcher.webp │ ├── raw │ ├── channels.txt │ └── index.html │ ├── values-zh-rCN │ └── strings.xml │ ├── values-zh-rTW │ └── strings.xml │ ├── values │ ├── colors.xml │ ├── dimens.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ └── network.xml ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── history.sh ├── screenshots ├── Screenshot_20240810_151748.png ├── Screenshot_20240813_232847.png ├── Screenshot_20240813_232900.png ├── appreciate.jpeg └── zfb.jpg ├── settings.gradle.kts └── version.json /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: 問題反饋 2 | description: 使用過程中遇到的錯誤或不合理功能 3 | labels: [ "bug" ] 4 | title: "[BUG] " 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: 提問題前請檢查 10 | description: 確認問題依然存在 11 | options: 12 | - label: 已檢查過問題列表,沒有人提過相同問題 13 | required: true 14 | - label: 軟件的最新版本依然沒有被修復 15 | required: true 16 | 17 | - type: input 18 | id: version 19 | attributes: 20 | label: 軟件版本 21 | validations: 22 | required: true 23 | 24 | - type: input 25 | id: device 26 | attributes: 27 | label: 電視品牌 28 | validations: 29 | required: true 30 | 31 | - type: input 32 | id: android 33 | attributes: 34 | label: Android版本 35 | validations: 36 | required: true 37 | 38 | - type: textarea 39 | id: bug 40 | attributes: 41 | label: 問題描述 42 | description: 請描述下問題的具體細節 43 | placeholder: | 44 | 1. 錯誤表現 45 | 2. 如何復現 46 | 3. 想法建議 47 | validations: 48 | required: true 49 | 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/fr.yml: -------------------------------------------------------------------------------- 1 | name: 需求建議 2 | description: 一個新的功能需求或建議 3 | labels: [ "enhancement" ] 4 | title: "[FR] " 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: 提需求前請檢查 10 | description: 確認新功能尚未被提出 11 | options: 12 | - label: 已檢查過需求列表,沒有人提過相同需求 13 | required: true 14 | 15 | - type: textarea 16 | id: solution 17 | attributes: 18 | label: 需求建議 19 | description: 需求需要單一且具體,請不要提諸如“優化界面”、“改善穩定性”這樣模糊的需求 20 | placeholder: | 21 | 1. 功能需求 22 | 2. 實現方案 23 | validations: 24 | required: true 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: set up JDK 17 19 | uses: actions/setup-java@v4 20 | with: 21 | java-version: '17' 22 | distribution: 'temurin' 23 | 24 | - name: Run build with Gradle wrapper 25 | run: ./gradlew assembleRelease 26 | 27 | - name: Sign app APK 28 | id: sign_app 29 | uses: r0adkll/sign-android-release@v1 30 | with: 31 | releaseDirectory: app/build/outputs/apk/release 32 | alias: ${{ secrets.ALIAS }} 33 | signingKeyBase64: ${{ secrets.KEYSTORE }} 34 | keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} 35 | keyPassword: ${{ secrets.ALIAS_PASSWORD }} 36 | env: 37 | # override default build-tools version (29.0.3) -- optional 38 | BUILD_TOOLS_VERSION: "34.0.0" 39 | 40 | - name: Get History 41 | id: get_history 42 | run: | 43 | chmod +x history.sh 44 | output=$(./history.sh) 45 | echo "$output" > history.md 46 | 47 | - name: Create Release 48 | id: create_release 49 | uses: actions/create-release@v1 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | with: 53 | tag_name: ${{ github.ref }} 54 | release_name: Release ${{ github.ref }} 55 | draft: false 56 | prerelease: false 57 | body_path: history.md 58 | 59 | - name: Upload Release Asset 60 | uses: actions/upload-release-asset@v1 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | with: 64 | upload_url: ${{ steps.create_release.outputs.upload_url }} 65 | asset_path: ${{ steps.sign_app.outputs.signedReleaseFile }} 66 | asset_name: my-tv-0-${{ github.ref_name }}.apk 67 | asset_content_type: application/vnd.android.package-archive 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea 4 | /local.properties 5 | /.idea/caches 6 | /.idea/libraries 7 | /.idea/modules.xml 8 | /.idea/workspace.xml 9 | /.idea/navEditor.xml 10 | /.idea/assetWizardSettings.xml 11 | .DS_Store 12 | /build 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | local.properties 17 | files 18 | .kotlin/ 19 | /release/ 20 | .vscode -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | ## 更新日誌 2 | 3 | ### v1.3.7.11 4 | 5 | * 修復閃退問題 6 | * 修復部分設備菜單異常問題 7 | 8 | ### v1.3.7.6 9 | 10 | * 增加錯誤上報 11 | 12 | ### v1.3.7 13 | 14 | * 時間顯示秒 15 | * 支持三位頻道號 16 | 17 | ### v1.3.6 18 | 19 | * 解决開機白屏问题 20 | * 恢復默認後自動播放默認頻道 21 | * 修復頻道記憶失敗的問題 22 | * 修復菜單部分情況下無法選擇的問題 23 | * 可設置EPG地址 24 | 25 | ### v1.3.5-kk 26 | - kk1 27 | * Merge from 1.3.5 28 | * Imporved compatibility 29 | * Change ghproxy.org to mirror.ghproxy.com 30 | 31 | ### v1.3.5 32 | 33 | * 可配置緊湊的菜單 34 | * 其他一些細節優化 35 | 36 | ### v1.3.4 37 | 38 | * 一些樣式優化 39 | 40 | ### v1.3.3 41 | 42 | * 一些樣式優化 43 | * 不同組頻道不合併 44 | * 防止頻道文件被覆蓋 45 | * 修復不顯示全部頻道的問題 46 | 47 | 48 | ### v1.3.2-kk 49 | - kk1 50 | * Merge from 1.3.2 51 | * Improve app startup speed 52 | * Detect and display if channel(line 0) is IPv6 53 | * Log video source metadata for debugging 54 | * Update dependencies to latest version 55 | 56 | ### v1.3.2 57 | 58 | * 固定遠程配置端口為34567 59 | * 優化配置樣式 60 | * 可配置是否顯示全部頻道 61 | 62 | ### v1.3.1 63 | 64 | * 恢復默認後,立即更新視頻源 65 | 66 | ### v1.3.0-kk 67 | - kk1 68 | * merge from 1.3.0 69 | 70 | ### v1.3.0 71 | 72 | * 修復收藏BUG 73 | 74 | ### v1.2.9 75 | 76 | * 同頻道多視頻地址合併 77 | * 更新了默認的視頻源,已安裝過的用戶需要在配置裡恢復默認 78 | * “收藏模式”下,上下按鍵頻道只會在收藏列表裡進行切換 79 | 80 | ### v1.2.8-kk 81 | - kk6 82 | * Improve playback compatibility and performance, fix a lot of bugs. 83 | - kk5 84 | * Support android kitkat 85 | * Improve https access module compatibility with android 4.4 86 | * Fixed a weird bug on Sharp TV: after the TV is started, this APP will start twice and stop playing after the second start. 87 | 88 | ### v1.2.8 89 | 90 | * 修復部分視頻源無法播放的問題 91 | * 修復一些閃退問題 92 | 93 | ### v1.2.7 94 | 95 | * 簡單支持EPG 96 | * 類別樣式優化 97 | 98 | ### v1.2.6 99 | 100 | * 解決切換頻道時黑屏問題 101 | * 解決部分配置地址請求失敗的問題 102 | * 支持配置代理 103 | 104 | ### v1.2.5 105 | 106 | * 配置地址兼容處理 107 | * 部分手機設備樣式兼容處理 108 | * 遙控器左鍵不再退出頻道列表 109 | * 解決視頻源文件分組不連續的問題 110 | * 應用啟動後進入我的收藏的功能暫不可用 111 | 112 | ### v1.2.3 113 | 114 | * 修復一些無法播放的問題 115 | 116 | ### v1.2.2 117 | 118 | * 修復一些無法播放的問題 119 | * 優化頻道列表樣式 120 | 121 | ### v1.2.1 122 | 123 | * 修復樣式 124 | 125 | ### v1.2.0 126 | 127 | * 修復部分設備網絡地址獲取錯誤的問題 128 | * 恢復默認的時候會清除收藏 129 | * 修復一些崩潰問題 130 | * 手機支持收藏功能 131 | 132 | ### v1.1.9 133 | 134 | * 菜單打開時,不能打開頻道列表 135 | * 頻道號大於1000以上時兼容樣式 136 | * 增加收藏功能 137 | 138 | ### v1.1.8 139 | 140 | * 頻道列表優化 141 | 142 | ### v1.1.7 143 | 144 | * 可以通過二維碼訪問配置地址 145 | * 支持自定義請求頭 146 | * 在線升級優化 147 | * 增加恢復默認 148 | * 增加dash支持 149 | 150 | ### v1.1.6 151 | 152 | * 默認頻道超出頻道列表範圍,自動設置為0 153 | * 通過網絡配置的頻道會自動保存 154 | * 可以通過網絡配置視頻源地址 155 | * 視頻源可以配置為本地文件 156 | 157 | ### v1.1.5 158 | 159 | * 可以指定默認頻道 160 | * 內置服務器,局域網內可配置 161 | 162 | ### v1.1.4 163 | 164 | * 默認使用上次緩存視頻源 165 | * 樣式優化 166 | 167 | ### v1.1.3 168 | 169 | * 修復m3u解析錯誤 170 | 171 | ### v1.1.2 172 | 173 | * 保存配置地址 174 | * 啟動後自動更新配置 175 | * 樣式優化 176 | 177 | ### v1.1.1 178 | 179 | * 優化頻道號選台 180 | * 如果沒有圖標,顯示頻道號 181 | 182 | ### v1.1.0 183 | 184 | * 優化頻道數字顯示 185 | * 增加時間顯示 186 | * 增加時間顯示配置 187 | 188 | ### v1.0.9 189 | 190 | * 減小頻道數字文字大小 191 | * 播放時背景顏色為黑色 192 | 193 | ### v1.0.8 194 | 195 | * 點擊節目列表/菜單以外區域,自動隱藏節目列表/菜單 196 | * 解決部分情況下崩潰問題 197 | 198 | ### v1.0.7 199 | 200 | * 支持rtsp直播 201 | * 支持循環播放 202 | * 支持txt/m3u視頻源 203 | 204 | ### v1.0.6 205 | 206 | * 修復視頻可能無聲音的問題 207 | * 修復視頻可能無法播放的問題 208 | 209 | ### v1.0.5 210 | 211 | * 修復頻道配置錯誤時可能崩潰的問題 212 | * 修復更新頻道配置時可能不生效的問題 213 | * 修復圖標為空時可能崩潰的問題 214 | 215 | ### v1.0.4 216 | 217 | * 在觸屏設備上雙擊打開節目列表 218 | * 支持自動更新 219 | 220 | ### v1.0.3 221 | 222 | * 保存上次頻道 223 | 224 | ### v1.0.2 225 | 226 | * 改變部分樣式 227 | 228 | ### v1.0.1 229 | 230 | * 支持返回鍵退出 231 | * 支持基本的視頻源配置 232 | 233 | ### v1.0.0 234 | 235 | * 基本視頻播放 -------------------------------------------------------------------------------- /HOWTOs.md: -------------------------------------------------------------------------------- 1 | # HOWTOs 2 | 3 | ## 1. Howto build lib-decoder-ffmpeg-release.aar on linux 4 | 5 | #### Download source code 6 | 7 | - Android NDK: 8 | 9 | https://github.com/android/ndk/wiki/Unsupported-Downloads 10 | 11 | API19: r25c https://dl.google.com/android/repository/android-ndk-r25c-linux.zip 12 | 13 | API17: r23c https://dl.google.com/android/repository/android-ndk-r23c-linux.zip 14 | 15 | - FFMPEG 16 | 17 | https://www.ffmpeg.org/download.html 18 | 19 | https://ffmpeg.org/releases/ffmpeg-7.0.1.tar.xz 20 | 21 | - Androidx media 22 | 23 | API19: https://github.com/androidx/media/tree/1.3.0 24 | 25 | API17: https://github.com/androidx/media/tree/1.2.1 26 | 27 | #### Build 28 | 29 | 0. Unzip all code to ~/ffmpeg 30 | 31 | 1. Setup env 32 | 33 | - API19 34 | ``` 35 | export ANDROID_ABI=19 36 | export MEDIA_RROJECT=~/ffmpeg/media-1.3.0 37 | export NDK_PATH=~/ffmpeg/android-ndk-r25c 38 | 39 | export FFMPEG_MODULE_PATH=$MEDIA_RROJECT/libraries/decoder_ffmpeg/src/main 40 | export FFMPEG_PATH=~/ffmpeg/ffmpeg-7.0.1 41 | export HOST_PLATFORM="linux-x86_64" 42 | export ENABLED_DECODERS=(vorbis opus flac alac mp3 aac ac3 eac3) 43 | ``` 44 | - API17: 45 | ``` 46 | export ANDROID_ABI=17 47 | export NDK_PATH=~/ffmpeg/android-ndk-r23c 48 | export MEDIA_RROJECT=~/ffmpeg/media-1.2.1 49 | 50 | export FFMPEG_MODULE_PATH=$MEDIA_RROJECT/libraries/decoder_ffmpeg/src/main 51 | ... 52 | ``` 53 | 54 | 2. Link ffmpeg source code 55 | 56 | ``` 57 | ln -s $FFMPEG_PATH $FFMPEG_MODULE_PATH/jni/ffmpeg 58 | ``` 59 | 60 | 3. Build ffmpeg 61 | 62 | Modify $FFMPEG_MODULE_PATH/jni/build_ffmpeg.sh if you need. 63 | ``` 64 | cd $FFMPEG_MODULE_PATH/jni 65 | ./build_ffmpeg.sh "${FFMPEG_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ANDROID_ABI}" "${ENABLED_DECODERS[@]}" 66 | ``` 67 | 68 | 4. Build aar 69 | 70 | modify common_library_config.gradle if you need. 71 | ``` 72 | defaultConfig { 73 | ...... 74 | minSdkVersion 19 #API17: 17 75 | ndk { 76 | abiFilters 'armeabi-v7a', 'arm64-v8a' 77 | } 78 | } 79 | ``` 80 | ``` 81 | cd $MEDIA_RROJECT 82 | ./gradlew lib-decoder-ffmpeg:assembleRelease 83 | ``` 84 | 85 | 5. Copy file to project 86 | 87 | ``` 88 | cd $MEDIA_RROJECT/libraries/decoder_ffmpeg/buildout/outputs/aar 89 | cp lib-decoder-ffmpeg-release.aar 90 | ``` 91 | 92 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: gen-version 2 | 3 | all: gen-version 4 | 5 | gen-version: 6 | git describe --tags --always 7 | git describe --tags --always | sed 's/v/ /g' | sed 's/\./ /g' | sed 's/-/ /g' | awk '{print ($$1*16777216)+($$2*65536)+($$3*256)+$$4}' 8 | 9 | #make gen v=1.2.3 10 | gen: 11 | echo $(v) | sed 's/v/ /g' | sed 's/\./ /g' | sed 's/-/ /g' | awk '{print "{\"version_code\": " ($$1*16777216)+($$2*65536)+($$3*256)+$$4 ", \"version_name\": \"" "v$(v)" "\"}"}' > version.json 12 | 13 | channels: 14 | gua64 -f -e files/channels.json -o app/src/main/res/raw/channels.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 声明 2 | ## 关于播放源或者IPV6网络问题请自行搜索解决! 3 | 本项目仅为自用修改,仅在android4.4的夏普电视测试,作者没有任何义务为任何人解决问题或添加功能,如果你遇到了问题,建议: 4 | 1. 自行修改源代码解决; 5 | 2. 使用AI如claude.ai协助解决; 6 | 3. 向本项目原作者求助 7 | 8 | # 版本说明 9 | * -kk :支持android 4.4,自用版本,推荐 10 | * ~~-ijk :支持android 4.4,播放器改成了ijkplayer,低端机器可能效果好些,自行测试~~ 效果不理想,停止开发 11 | * -jb :支持andriod 4.2/4.3 ,未经测试,后续不维护 12 | 13 | # 我的電視·〇 14 | 15 | 電視網絡視頻播放軟件,可以自定義視頻源 16 | 17 | [my-tv-0](https://github.com/lizongying/my-tv-0) 18 | 19 | ## 使用 20 | 21 | * 遙控器左鍵/觸屏單擊打開節目列表 22 | * 遙控器右鍵/觸屏雙擊打開配置 23 | * 遙控器返回鍵關閉節目列表/配置 24 | * 打開配置頁后,選擇遠程配置,掃描二維碼可以配置視頻源地址等。 25 | * 如果視頻源地址已配置,並且打開了“啟動后自動更新視頻源”后,軟件啟動后自動更新視頻源 26 | * 在節目列表顯示的時候,右鍵收藏/取消收藏 27 | 28 | 注意: 29 | 30 | * 視頻源可以設置為本地文件,格式如:file:///mnt/sdcard/tmp/channels.m3u 31 | /channels.m3u 32 | 33 | 目前支持的配置格式: 34 | 35 | * txt 36 | ``` 37 | 組名,#genre# 38 | 標題,視頻地址 39 | ``` 40 | * m3u 41 | ``` 42 | #EXTM3U 43 | #EXTINF:-1 tvg-name="標準標題" tvg-logo="图标" group-title="組名",標題 44 | 視頻地址 45 | ``` 46 | * json 47 | ```json 48 | [ 49 | { 50 | "group": "組名", 51 | "logo": "图标", 52 | "name": "標準標題", 53 | "title": "標題", 54 | "uris": [ 55 | "視頻地址" 56 | ], 57 | "headers": { 58 | "user-agent": "" 59 | } 60 | } 61 | ] 62 | ``` 63 | 64 | 推薦配合使用 [my-tv-server](https://github.com/lizongying/my-tv-server) 65 | 66 | 下載安裝 [releases](https://github.com/lizongying/my-tv-0/releases/) 67 | 68 | 更多地址 [my-tv-0](https://lyrics.run/my-tv-0.html) 69 | 70 | ![image](./screenshots/Screenshot_20240810_151748.png) 71 | ![image](./screenshots/Screenshot_20240813_232847.png) 72 | ![image](./screenshots/Screenshot_20240813_232900.png) 73 | 74 | ## 更新日誌 75 | 76 | [更新日誌](./HISTORY.md) 77 | 78 | ## 其他 79 | 80 | 建議通過ADB進行安裝: 81 | 82 | ```shell 83 | adb install my-tv-0.apk 84 | ``` 85 | 86 | 小米電視可以使用小米電視助手進行安裝 87 | 88 | ## TODO 89 | 90 | * 視頻解碼 91 | * 支持回看 92 | * 詳細EPG 93 | * 淺色菜單 94 | * 無效的頻道? 95 | * 判断文件是否被修改 96 | * 多源管理 97 | * 如果上次播放頻道不在收藏? 98 | * 當list為空,顯示group 99 | * 默認頻道菜單顯示 100 | 101 | ## 讚賞 102 | 103 | ![image](./screenshots/appreciate.jpeg) 104 | 105 | ## 感謝 106 | 107 | [live](https://github.com/fanmingming/live) -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /release/ -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.io.BufferedReader 2 | 3 | plugins { 4 | alias(libs.plugins.android.application) 5 | alias(libs.plugins.kotlin.android) 6 | } 7 | 8 | android { 9 | namespace = "com.lizongying.mytv0" 10 | compileSdk = 34 11 | 12 | defaultConfig { 13 | applicationId = "com.lizongying.mytv0" 14 | minSdk = 19 15 | targetSdk = 34 16 | versionCode = getVersionCode() 17 | versionName = getVersionName() 18 | multiDexEnabled = true 19 | } 20 | 21 | buildFeatures { 22 | viewBinding = true 23 | } 24 | 25 | buildTypes { 26 | release { 27 | isMinifyEnabled = false 28 | proguardFiles( 29 | getDefaultProguardFile("proguard-android-optimize.txt"), 30 | "proguard-rules.pro" 31 | ) 32 | } 33 | } 34 | compileOptions { 35 | // Flag to enable support for the new language APIs 36 | 37 | // For AGP 4.1+ 38 | isCoreLibraryDesugaringEnabled = true 39 | 40 | sourceCompatibility = JavaVersion.VERSION_1_8 41 | targetCompatibility = JavaVersion.VERSION_1_8 42 | } 43 | kotlinOptions { 44 | jvmTarget = "1.8" 45 | } 46 | } 47 | 48 | fun getVersionCode(): Int { 49 | return try { 50 | val process = Runtime.getRuntime().exec("git describe --tags --always") 51 | process.waitFor() 52 | val arr = (process.inputStream.bufferedReader().use(BufferedReader::readText).trim() 53 | .replace("v", "").replace(".", " ").replace("-", " ") + " 0").split(" ") 54 | val versionCode = 55 | arr[0].toInt() * 16777216 + arr[1].toInt() * 65536 + arr[2].toInt() * 256 + arr[3].toInt() 56 | versionCode 57 | } catch (ignored: Exception) { 58 | 1 59 | } 60 | } 61 | 62 | fun getVersionName(): String { 63 | return try { 64 | val process = Runtime.getRuntime().exec("git describe --tags --always") 65 | process.waitFor() 66 | val versionName = process.inputStream.bufferedReader().use(BufferedReader::readText).trim() 67 | .removePrefix("v") 68 | versionName.ifEmpty { 69 | "1.0.0" 70 | } 71 | } catch (ignored: Exception) { 72 | "1.0.0" 73 | } 74 | } 75 | 76 | task("modifySource") { 77 | val net = project.findProperty("net") ?: "" 78 | println("net: $net") 79 | 80 | val channels = when (net) { 81 | "ipv6" -> "assets/ipv6.txt" 82 | "mobile" -> "assets/mobile.txt" 83 | else -> "assets/common.txt" 84 | } 85 | 86 | println("channels: $channels") 87 | 88 | inputs.file(channels) 89 | outputs.file("src/main/res/raw/channels.txt") 90 | doLast { 91 | if (channels.isNotEmpty()) { 92 | val sourceFile = file(channels) 93 | val targetFile = file("src/main/res/raw/channels.txt") 94 | targetFile.writeText(sourceFile.readText()) 95 | println(targetFile.readText()) 96 | } 97 | 98 | val url = when (net) { 99 | "ipv6" -> "DEFAULT_CONFIG_URL = \"https://live.fanmingming.com/tv/m3u/ipv6.m3u\"" 100 | "mobile" -> "DEFAULT_CONFIG_URL = \"https://live.fanmingming.com/tv/m3u/itv.m3u\"" 101 | else -> "" 102 | } 103 | 104 | if (url.isNotEmpty()) { 105 | val f = file("src/main/java/com/lizongying/mytv0/SP.kt") 106 | f.writeText(f.readText().replace("DEFAULT_CONFIG_URL = \"\"", url)) 107 | } 108 | } 109 | } 110 | 111 | tasks.whenTaskAdded { 112 | if (name == "assembleRelease") { 113 | dependsOn("modifySource") 114 | doLast { 115 | val net = project.findProperty("net") ?: "" 116 | println("net: $net") 117 | 118 | val url = when (net) { 119 | "ipv6" -> "DEFAULT_CONFIG_URL = \"https://live.fanmingming.com/tv/m3u/ipv6.m3u\"" 120 | "mobile" -> "DEFAULT_CONFIG_URL = \"https://live.fanmingming.com/tv/m3u/itv.m3u\"" 121 | else -> "" 122 | } 123 | 124 | if (url.isNotEmpty()) { 125 | val f = file("src/main/java/com/lizongying/mytv0/SP.kt") 126 | f.writeText(f.readText().replace(url, "DEFAULT_CONFIG_URL = \"\"")) 127 | } 128 | } 129 | } 130 | 131 | if (listOf( 132 | "packageReleaseResources", 133 | "mergeReleaseResources", 134 | "generateReleaseResources", 135 | "mapReleaseSourceSetPaths", 136 | ).contains(name) 137 | ) { 138 | dependsOn("modifySource") 139 | } 140 | } 141 | 142 | dependencies { 143 | // For AGP 7.4+ 144 | coreLibraryDesugaring(libs.desugar.jdk.libs) 145 | 146 | implementation(libs.media3.ui) 147 | implementation(libs.media3.exoplayer) 148 | implementation(libs.media3.exoplayer.hls) 149 | implementation(libs.media3.exoplayer.dash) 150 | implementation(libs.media3.exoplayer.rtsp) 151 | //implementation(libs.media3.datasource.okhttp) 152 | 153 | implementation(libs.nanohttpd) 154 | implementation(libs.gua64) 155 | implementation(libs.zxing) 156 | implementation(libs.glide) 157 | 158 | implementation(libs.gson) 159 | implementation(libs.okhttp) 160 | implementation(libs.converter.gson) 161 | implementation(libs.retrofit) 162 | 163 | implementation(libs.core.ktx) 164 | implementation(libs.coroutines) 165 | 166 | implementation(libs.constraintlayout) 167 | implementation(libs.appcompat) 168 | implementation(libs.recyclerview) 169 | implementation(libs.lifecycle.viewmodel) 170 | 171 | implementation(files("libs/lib-decoder-ffmpeg-release.aar")) 172 | 173 | implementation(libs.conscrypt) 174 | implementation(libs.okhttp.logging) 175 | } -------------------------------------------------------------------------------- /app/libs/lib-decoder-ffmpeg-release.aar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/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/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 34 | 35 | 36 | 41 | 42 | 43 | 46 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/BootReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | 7 | class BootReceiver : BroadcastReceiver() { 8 | 9 | override fun onReceive(context: Context, intent: Intent) { 10 | if (SP.bootStartup) { 11 | try { 12 | context.startActivity( 13 | Intent(context, MainActivity::class.java) 14 | .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 15 | ) 16 | } catch (e: Exception) { 17 | e.printStackTrace() 18 | } 19 | } 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/ChannelFragment.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import MainViewModel 4 | import android.os.Bundle 5 | import android.os.Handler 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import androidx.core.view.marginEnd 10 | import androidx.core.view.marginTop 11 | import androidx.fragment.app.Fragment 12 | import androidx.lifecycle.ViewModelProvider 13 | import com.lizongying.mytv0.databinding.ChannelBinding 14 | import com.lizongying.mytv0.models.TVModel 15 | 16 | class ChannelFragment : Fragment() { 17 | private var _binding: ChannelBinding? = null 18 | private val binding get() = _binding!! 19 | 20 | private val handler = Handler() 21 | private val delay: Long = 5000 22 | private var channel = 0 23 | private var channelCount = 0 24 | 25 | private lateinit var viewModel: MainViewModel 26 | 27 | override fun onCreateView( 28 | inflater: LayoutInflater, container: ViewGroup?, 29 | savedInstanceState: Bundle? 30 | ): View { 31 | _binding = ChannelBinding.inflate(inflater, container, false) 32 | _binding!!.root.visibility = View.GONE 33 | 34 | val application = requireActivity().applicationContext as MyTVApplication 35 | 36 | binding.channel.layoutParams.width = application.px2Px(binding.channel.layoutParams.width) 37 | binding.channel.layoutParams.height = application.px2Px(binding.channel.layoutParams.height) 38 | 39 | val layoutParams = binding.channel.layoutParams as ViewGroup.MarginLayoutParams 40 | layoutParams.topMargin = application.px2Px(binding.channel.marginTop) 41 | layoutParams.marginEnd = application.px2Px(binding.channel.marginEnd) 42 | binding.channel.layoutParams = layoutParams 43 | 44 | binding.content.textSize = application.px2PxFont(binding.content.textSize) 45 | binding.time.textSize = application.px2PxFont(binding.time.textSize) 46 | 47 | binding.main.layoutParams.width = application.shouldWidthPx() 48 | binding.main.layoutParams.height = application.shouldHeightPx() 49 | 50 | return binding.root 51 | } 52 | 53 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 54 | super.onViewCreated(view, savedInstanceState) 55 | val context = requireActivity() 56 | viewModel = ViewModelProvider(context)[MainViewModel::class.java] 57 | } 58 | 59 | fun show(tvViewModel: TVModel) { 60 | handler.removeCallbacks(hideRunnable) 61 | handler.removeCallbacks(playRunnable) 62 | binding.content.text = (tvViewModel.tv.id.plus(1)).toString() 63 | view?.visibility = View.VISIBLE 64 | handler.postDelayed(hideRunnable, delay) 65 | } 66 | 67 | fun show(channel: String) { 68 | if (viewModel.groupModel.getCurrent()!!.tv.id > 10 && viewModel.groupModel.getCurrent()!!.tv.id == this.channel - 1) { 69 | this.channel = 0 70 | channelCount = 0 71 | } 72 | if (channelCount > 2) { 73 | return 74 | } 75 | channelCount++ 76 | this.channel = "${this.channel}$channel".toInt() 77 | handler.removeCallbacks(hideRunnable) 78 | handler.removeCallbacks(playRunnable) 79 | if (channelCount < 3) { 80 | binding.content.text = this.channel.toString() 81 | view?.visibility = View.VISIBLE 82 | handler.postDelayed(playRunnable, delay) 83 | } else { 84 | handler.postDelayed(playRunnable, 0) 85 | } 86 | } 87 | 88 | override fun onResume() { 89 | super.onResume() 90 | if (view?.visibility == View.VISIBLE) { 91 | handler.postDelayed(hideRunnable, delay) 92 | } 93 | } 94 | 95 | override fun onPause() { 96 | super.onPause() 97 | handler.removeCallbacks(hideRunnable) 98 | handler.removeCallbacks(playRunnable) 99 | } 100 | 101 | private val hideRunnable = Runnable { 102 | if (_binding != null) { 103 | binding.content.text = BLANK 104 | } 105 | 106 | view?.visibility = View.GONE 107 | channel = 0 108 | channelCount = 0 109 | } 110 | 111 | private val playRunnable = Runnable { 112 | (activity as MainActivity).play(channel - 1) 113 | handler.postDelayed(hideRunnable, delay) 114 | } 115 | 116 | override fun onDestroyView() { 117 | super.onDestroyView() 118 | _binding = null 119 | } 120 | 121 | companion object { 122 | private const val TAG = "ChannelFragment" 123 | private const val BLANK = "" 124 | } 125 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/ConfirmationFragment.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.app.AlertDialog 4 | import android.app.Dialog 5 | import android.os.Bundle 6 | import androidx.fragment.app.DialogFragment 7 | 8 | class ConfirmationFragment( 9 | private val listener: ConfirmationListener, 10 | private val message: String, 11 | private val update: Boolean 12 | ) : DialogFragment() { 13 | 14 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 15 | return activity?.let { 16 | val builder = AlertDialog.Builder(it) 17 | builder.setTitle(message) 18 | if (update) { 19 | builder.setMessage("确定更新吗?") 20 | .setPositiveButton( 21 | "确定" 22 | ) { _, _ -> 23 | listener.onConfirm() 24 | } 25 | .setNegativeButton( 26 | "取消" 27 | ) { _, _ -> 28 | listener.onCancel() 29 | } 30 | } else { 31 | builder.setMessage("") 32 | .setNegativeButton( 33 | "确定" 34 | ) { _, _ -> 35 | } 36 | } 37 | builder.create() 38 | } ?: throw IllegalStateException("Activity cannot be null") 39 | } 40 | 41 | interface ConfirmationListener { 42 | fun onConfirm() 43 | fun onCancel() 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/ErrorFragment.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.view.marginTop 8 | import androidx.fragment.app.Fragment 9 | import com.lizongying.mytv0.databinding.ErrorBinding 10 | 11 | class ErrorFragment : Fragment() { 12 | private var _binding: ErrorBinding? = null 13 | private val binding get() = _binding!! 14 | 15 | override fun onCreateView( 16 | inflater: LayoutInflater, container: ViewGroup?, 17 | savedInstanceState: Bundle? 18 | ): View { 19 | _binding = ErrorBinding.inflate(inflater, container, false) 20 | 21 | val application = requireActivity().applicationContext as MyTVApplication 22 | 23 | binding.logo.layoutParams.width = application.px2Px(binding.logo.layoutParams.width) 24 | binding.logo.layoutParams.height = application.px2Px(binding.logo.layoutParams.height) 25 | 26 | val layoutParams = binding.msg.layoutParams as ViewGroup.MarginLayoutParams 27 | layoutParams.topMargin = application.px2Px(binding.msg.marginTop) 28 | binding.msg.layoutParams = layoutParams 29 | 30 | binding.msg.textSize = application.px2PxFont(binding.msg.textSize) 31 | 32 | _binding = ErrorBinding.inflate(inflater, container, false) 33 | return binding.root 34 | } 35 | 36 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 37 | super.onViewCreated(view, savedInstanceState) 38 | (activity as MainActivity).ready(TAG) 39 | } 40 | 41 | fun setMsg(msg: String) { 42 | if (_binding != null) { 43 | binding.msg.text = msg 44 | } 45 | } 46 | 47 | override fun onDestroyView() { 48 | super.onDestroyView() 49 | _binding = null 50 | } 51 | 52 | companion object { 53 | private const val TAG = "ErrorFragment" 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/Ext.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package com.lizongying.mytv0 4 | 5 | import android.content.Context 6 | import android.content.pm.PackageInfo 7 | import android.content.pm.PackageManager 8 | import android.content.pm.Signature 9 | import android.content.pm.SigningInfo 10 | import android.os.Build 11 | import android.util.Log 12 | import android.widget.Toast 13 | import java.security.MessageDigest 14 | import java.util.regex.Pattern 15 | 16 | private const val TAG = "Extensions" 17 | 18 | private val Context.packageInfo: PackageInfo 19 | get() { 20 | val flag = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { 21 | PackageManager.GET_SIGNATURES 22 | } else { 23 | PackageManager.GET_SIGNING_CERTIFICATES 24 | } 25 | return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { 26 | packageManager.getPackageInfo(packageName, flag) 27 | } else { 28 | packageManager.getPackageInfo( 29 | packageName, 30 | PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()) 31 | ) 32 | } 33 | } 34 | 35 | /** 36 | * Return the version code of the app which is defined in build.gradle. 37 | * eg:100 38 | */ 39 | val Context.appVersionCode: Long 40 | get() { 41 | val packageInfo = this.packageInfo 42 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 43 | packageInfo.longVersionCode 44 | } else { 45 | packageInfo.versionCode.toLong() 46 | } 47 | } 48 | 49 | /** 50 | * Return the version name of the app which is defined in build.gradle. 51 | * eg:1.0.0 52 | */ 53 | val Context.appVersionName: String get() = packageInfo.versionName 54 | 55 | val Context.appSignature: String 56 | get() { 57 | val packageInfo = this.packageInfo 58 | var sign: Signature? = null 59 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { 60 | val signatures: Array? = packageInfo.signatures 61 | if (signatures != null) { 62 | sign = signatures[0] 63 | } 64 | } else { 65 | val signingInfo: SigningInfo? = packageInfo.signingInfo 66 | if (signingInfo != null) { 67 | sign = signingInfo.apkContentsSigners[0] 68 | } 69 | } 70 | if (sign == null) { 71 | return "" 72 | } 73 | return hashSignature(sign) 74 | } 75 | 76 | private fun hashSignature(signature: Signature): String { 77 | return try { 78 | val md = MessageDigest.getInstance("MD5") 79 | md.update(signature.toByteArray()) 80 | val digest = md.digest() 81 | digest.let { it -> it.joinToString("") { "%02x".format(it) } } 82 | } catch (e: Exception) { 83 | Log.e(TAG, "Error hashing signature", e) 84 | "" 85 | } 86 | } 87 | 88 | fun String.showToast(duration: Int = Toast.LENGTH_SHORT) { 89 | MyTVApplication.getInstance().toast(this, duration) 90 | } 91 | 92 | fun Int.getString(): String { 93 | return MyTVApplication.getInstance().getString(this) 94 | } 95 | 96 | fun Int.showToast(duration: Int = Toast.LENGTH_SHORT) { 97 | this.getString().showToast(duration) 98 | } 99 | 100 | fun String.md5(): String { 101 | val md = MessageDigest.getInstance("MD5") 102 | val digest = md.digest(this.toByteArray()) 103 | return digest.joinToString("") { String.format("%02x", it) } 104 | } 105 | 106 | fun String.isIPv6(): Boolean { 107 | val urlPattern = Pattern.compile( 108 | "^((http|https)://)?(\\[[0-9a-fA-F:]+])(:[0-9]+)?(/.*)?$" 109 | ) 110 | return urlPattern.matcher(this).matches() 111 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/GroupAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import android.view.KeyEvent 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import androidx.core.content.ContextCompat 10 | import androidx.core.view.marginBottom 11 | import androidx.core.view.marginStart 12 | import androidx.recyclerview.widget.LinearLayoutManager 13 | import androidx.recyclerview.widget.RecyclerView 14 | import com.lizongying.mytv0.databinding.GroupItemBinding 15 | import com.lizongying.mytv0.models.TVGroupModel 16 | import com.lizongying.mytv0.models.TVListModel 17 | 18 | 19 | class GroupAdapter( 20 | private val context: Context, 21 | private val recyclerView: RecyclerView, 22 | private var tvGroupModel: TVGroupModel, 23 | ) : 24 | RecyclerView.Adapter() { 25 | 26 | private var listener: ItemListener? = null 27 | private var focused: View? = null 28 | private var defaultFocused = false 29 | private var defaultFocus: Int = -1 30 | 31 | var visiable = false 32 | 33 | private var first = true 34 | 35 | val application = context.applicationContext as MyTVApplication 36 | 37 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 38 | val inflater = LayoutInflater.from(context) 39 | val binding = GroupItemBinding.inflate(inflater, parent, false) 40 | 41 | val layoutParams = binding.title.layoutParams as ViewGroup.MarginLayoutParams 42 | layoutParams.marginStart = application.px2Px(binding.title.marginStart) 43 | layoutParams.bottomMargin = application.px2Px(binding.title.marginBottom) 44 | binding.title.layoutParams = layoutParams 45 | 46 | binding.title.textSize = application.px2PxFont(binding.title.textSize) 47 | 48 | binding.root.isFocusable = true 49 | binding.root.isFocusableInTouchMode = true 50 | return ViewHolder(context, binding) 51 | } 52 | 53 | fun focusable(able: Boolean) { 54 | recyclerView.isFocusable = able 55 | recyclerView.isFocusableInTouchMode = able 56 | if (able) { 57 | recyclerView.descendantFocusability = ViewGroup.FOCUS_BEFORE_DESCENDANTS 58 | } else { 59 | recyclerView.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS 60 | } 61 | } 62 | 63 | fun clear() { 64 | focused?.clearFocus() 65 | recyclerView.invalidate() 66 | } 67 | 68 | override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { 69 | val listTVModel = tvGroupModel.getTVListModel(position)!! 70 | val view = viewHolder.itemView 71 | 72 | if (!defaultFocused && position == defaultFocus) { 73 | view.requestFocus() 74 | defaultFocused = true 75 | } 76 | 77 | val onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> 78 | listener?.onItemFocusChange(listTVModel, hasFocus) 79 | 80 | if (hasFocus) { 81 | viewHolder.focus(true) 82 | focused = view 83 | if (visiable) { 84 | 85 | // "position" should not be used here, as the "list" may have been filtered out. 86 | val p = listTVModel.getGroupIndex() 87 | if (p != tvGroupModel.positionValue) { 88 | tvGroupModel.setPosition(p) 89 | } 90 | } else { 91 | visiable = true 92 | } 93 | } else { 94 | viewHolder.focus(false) 95 | } 96 | } 97 | 98 | view.onFocusChangeListener = onFocusChangeListener 99 | 100 | view.setOnClickListener { _ -> 101 | listener?.onItemClicked(position) 102 | } 103 | 104 | view.setOnKeyListener { _, keyCode, event: KeyEvent? -> 105 | if (event?.action == KeyEvent.ACTION_UP) { 106 | recyclerView.postDelayed({ 107 | val oldLikeMode = tvGroupModel.isInLikeMode; 108 | tvGroupModel.isInLikeMode = position == 0 109 | if (tvGroupModel.isInLikeMode) { 110 | // R.string.favorite_mode.showToast() 111 | } else if (oldLikeMode) { 112 | // R.string.standard_mode.showToast() 113 | } 114 | }, 500) 115 | } 116 | if (event?.action == KeyEvent.ACTION_DOWN) { 117 | 118 | // If it is already the first item and you continue to move up... 119 | if (keyCode == KeyEvent.KEYCODE_DPAD_UP && position == 0) { 120 | val p = getItemCount() - 1 121 | 122 | (recyclerView.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset( 123 | p, 124 | 0 125 | ) 126 | 127 | recyclerView.postDelayed({ 128 | val v = recyclerView.findViewHolderForAdapterPosition(p) 129 | v?.itemView?.isSelected = true 130 | v?.itemView?.requestFocus() 131 | }, 0) 132 | } 133 | 134 | // If it is the last item and you continue to move down... 135 | if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && position == getItemCount() - 1) { 136 | val p = 0 137 | 138 | (recyclerView.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset( 139 | p, 140 | 0 141 | ) 142 | 143 | recyclerView.postDelayed({ 144 | val v = recyclerView.findViewHolderForAdapterPosition(p) 145 | v?.itemView?.isSelected = true 146 | v?.itemView?.requestFocus() 147 | }, 0) 148 | } 149 | 150 | return@setOnKeyListener listener?.onKey(keyCode) ?: false 151 | } 152 | false 153 | } 154 | 155 | viewHolder.bindTitle(listTVModel.getName()) 156 | } 157 | 158 | override fun getItemCount() = tvGroupModel.size() 159 | 160 | class ViewHolder(private val context: Context, private val binding: GroupItemBinding) : 161 | RecyclerView.ViewHolder(binding.root) { 162 | fun bindTitle(text: String) { 163 | binding.title.text = when (text) { 164 | "我的收藏" -> context.getString(R.string.my_favorites) 165 | "全部頻道" -> context.getString(R.string.all_channels) 166 | else -> text 167 | } 168 | } 169 | 170 | fun focus(hasFocus: Boolean) { 171 | if (hasFocus) { 172 | binding.title.setTextColor(ContextCompat.getColor(context, R.color.focus)) 173 | } else { 174 | binding.title.setTextColor( 175 | ContextCompat.getColor( 176 | context, 177 | R.color.title_blur 178 | ) 179 | ) 180 | } 181 | } 182 | } 183 | 184 | fun scrollToPositionAndSelect(position: Int) { 185 | val layoutManager = recyclerView.layoutManager as? LinearLayoutManager 186 | layoutManager?.let { 187 | val delay = if (first) { 188 | 100L 189 | } else { 190 | first = false 191 | 0 192 | } 193 | 194 | recyclerView.postDelayed({ 195 | val groupPosition = 196 | if (SP.showAllChannels || position == 0) position else position - 1 197 | it.scrollToPositionWithOffset(groupPosition, 0) 198 | 199 | val viewHolder = recyclerView.findViewHolderForAdapterPosition(groupPosition) 200 | viewHolder?.itemView?.apply { 201 | isSelected = true 202 | requestFocus() 203 | } 204 | }, delay) 205 | } 206 | } 207 | 208 | interface ItemListener { 209 | fun onItemFocusChange(listTVModel: TVListModel, hasFocus: Boolean) 210 | fun onItemClicked(position: Int) 211 | fun onKey(keyCode: Int): Boolean 212 | } 213 | 214 | fun setItemListener(listener: ItemListener) { 215 | this.listener = listener 216 | } 217 | 218 | fun update(tvGroupModel: TVGroupModel) { 219 | this.tvGroupModel = tvGroupModel 220 | recyclerView.post { 221 | notifyDataSetChanged() 222 | } 223 | } 224 | 225 | companion object { 226 | private const val TAG = "GroupAdapter" 227 | } 228 | } 229 | 230 | -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/IgnoreSSLCertificate.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import java.security.cert.X509Certificate 4 | import javax.net.ssl.HostnameVerifier 5 | import javax.net.ssl.HttpsURLConnection 6 | import javax.net.ssl.SSLContext 7 | import javax.net.ssl.TrustManager 8 | import javax.net.ssl.X509TrustManager 9 | 10 | object IgnoreSSLCertificate { 11 | 12 | fun ignore() { 13 | try { 14 | val trustAllCerts: Array = arrayOf( 15 | object : X509TrustManager { 16 | override fun getAcceptedIssuers(): Array? { 17 | return null 18 | } 19 | 20 | override fun checkClientTrusted( 21 | certs: Array?, 22 | authType: String? 23 | ) { 24 | } 25 | 26 | override fun checkServerTrusted( 27 | certs: Array?, 28 | authType: String? 29 | ) { 30 | } 31 | } 32 | ) 33 | 34 | // Install the all-trusting trust manager 35 | val sc = SSLContext.getInstance("SSL") 36 | sc.init(null, trustAllCerts, java.security.SecureRandom()) 37 | HttpsURLConnection.setDefaultSSLSocketFactory(sc.socketFactory) 38 | 39 | // Create all-trusting host name verifier 40 | val allHostsValid = HostnameVerifier { _, _ -> true } 41 | 42 | // Install the all-trusting host verifier 43 | HttpsURLConnection.setDefaultHostnameVerifier(allHostsValid) 44 | } catch (e: Exception) { 45 | e.printStackTrace() 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/InfoFragment.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.graphics.Bitmap 4 | import android.graphics.Canvas 5 | import android.graphics.Paint 6 | import android.graphics.drawable.BitmapDrawable 7 | import android.os.Bundle 8 | import android.os.Handler 9 | import android.view.LayoutInflater 10 | import android.view.View 11 | import android.view.ViewGroup 12 | import androidx.core.content.ContextCompat 13 | import androidx.core.view.marginBottom 14 | import androidx.core.view.marginStart 15 | import androidx.core.view.marginTop 16 | import androidx.fragment.app.Fragment 17 | import com.bumptech.glide.Glide 18 | import com.lizongying.mytv0.databinding.InfoBinding 19 | import com.lizongying.mytv0.models.TVModel 20 | 21 | 22 | class InfoFragment : Fragment() { 23 | private var _binding: InfoBinding? = null 24 | private val binding get() = _binding!! 25 | 26 | private val handler = Handler() 27 | private val delay: Long = 5000 28 | 29 | override fun onCreateView( 30 | inflater: LayoutInflater, container: ViewGroup?, 31 | savedInstanceState: Bundle? 32 | ): View { 33 | _binding = InfoBinding.inflate(inflater, container, false) 34 | 35 | val application = requireActivity().applicationContext as MyTVApplication 36 | 37 | binding.info.layoutParams.width = application.px2Px(binding.info.layoutParams.width) 38 | binding.info.layoutParams.height = application.px2Px(binding.info.layoutParams.height) 39 | 40 | val layoutParams = binding.info.layoutParams as ViewGroup.MarginLayoutParams 41 | layoutParams.bottomMargin = application.px2Px(binding.info.marginBottom) 42 | binding.info.layoutParams = layoutParams 43 | 44 | binding.logo.layoutParams.width = application.px2Px(binding.logo.layoutParams.width) 45 | var padding = application.px2Px(binding.logo.paddingTop) 46 | binding.logo.setPadding(padding, padding, padding, padding) 47 | binding.main.layoutParams.width = application.px2Px(binding.main.layoutParams.width) 48 | padding = application.px2Px(binding.main.paddingTop) 49 | binding.main.setPadding(padding, padding, padding, padding) 50 | 51 | val layoutParamsMain = binding.main.layoutParams as ViewGroup.MarginLayoutParams 52 | layoutParamsMain.marginStart = application.px2Px(binding.main.marginStart) 53 | binding.main.layoutParams = layoutParamsMain 54 | 55 | val layoutParamsDesc = binding.desc.layoutParams as ViewGroup.MarginLayoutParams 56 | layoutParamsDesc.topMargin = application.px2Px(binding.desc.marginTop) 57 | binding.desc.layoutParams = layoutParamsDesc 58 | 59 | binding.title.textSize = application.px2PxFont(binding.title.textSize) 60 | binding.desc.textSize = application.px2PxFont(binding.desc.textSize) 61 | 62 | binding.container.layoutParams.width = application.shouldWidthPx() 63 | binding.container.layoutParams.height = application.shouldHeightPx() 64 | 65 | _binding!!.root.visibility = View.GONE 66 | return binding.root 67 | } 68 | 69 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 70 | super.onViewCreated(view, savedInstanceState) 71 | (activity as MainActivity).ready(TAG) 72 | } 73 | 74 | fun show(tvViewModel: TVModel) { 75 | val context = requireContext() 76 | //TODO need get current source url 77 | val ipv6Text = tvViewModel.tv.uris[0].isIPv6().let { if (it) " | IPV6" else "" } 78 | binding.title.text = "${tvViewModel.tv.title}$ipv6Text" 79 | 80 | when (tvViewModel.tv.title) { 81 | else -> { 82 | val width = Utils.dpToPx(100) 83 | val height = Utils.dpToPx(60) 84 | val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) 85 | val canvas = Canvas(bitmap) 86 | 87 | val text = "${tvViewModel.tv.id + 1}" 88 | var size = 100f 89 | if (tvViewModel.tv.id > 999) { 90 | size = 90f 91 | } 92 | val paint = Paint().apply { 93 | color = ContextCompat.getColor(context, R.color.title_blur) 94 | textSize = size 95 | textAlign = Paint.Align.CENTER 96 | } 97 | val x = width / 2f 98 | val y = height / 2f - (paint.descent() + paint.ascent()) / 2 99 | canvas.drawText(text, x, y, paint) 100 | 101 | if (tvViewModel.tv.logo.isNullOrBlank()) { 102 | Glide.with(this) 103 | .load(BitmapDrawable(context.resources, bitmap)) 104 | // .centerInside() 105 | .into(binding.logo) 106 | } else { 107 | Glide.with(this) 108 | .load(tvViewModel.tv.logo) 109 | // .placeholder(BitmapDrawable(context.resources, bitmap)) 110 | .error(BitmapDrawable(context.resources, bitmap)) 111 | // .centerInside() 112 | .into(binding.logo) 113 | } 114 | } 115 | } 116 | 117 | val epg = tvViewModel.epg.value?.filter { it.beginTime < Utils.getDateTimestamp() } 118 | if (!epg.isNullOrEmpty()) { 119 | binding.desc.text = epg.last().title 120 | } else { 121 | binding.desc.text = "精彩節目" 122 | } 123 | 124 | handler.removeCallbacks(removeRunnable) 125 | view?.visibility = View.VISIBLE 126 | handler.postDelayed(removeRunnable, delay) 127 | } 128 | 129 | override fun onResume() { 130 | super.onResume() 131 | handler.postDelayed(removeRunnable, delay) 132 | } 133 | 134 | override fun onPause() { 135 | super.onPause() 136 | handler.removeCallbacks(removeRunnable) 137 | } 138 | 139 | private val removeRunnable = Runnable { 140 | view?.visibility = View.GONE 141 | } 142 | 143 | override fun onDestroyView() { 144 | super.onDestroyView() 145 | _binding = null 146 | } 147 | 148 | companion object { 149 | private const val TAG = "InfoFragment" 150 | } 151 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/InitializerProvider.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.content.ContentProvider 4 | import android.content.ContentValues 5 | import android.net.Uri 6 | 7 | internal class InitializerProvider : ContentProvider() { 8 | 9 | // Happens before Application#onCreate.It's fine to init something here 10 | override fun onCreate(): Boolean { 11 | SP.init(context!!) 12 | return true 13 | } 14 | 15 | override fun query( 16 | uri: Uri, 17 | projection: Array?, 18 | selection: String?, 19 | selectionArgs: Array?, 20 | sortOrder: String?, 21 | ) = unsupported() 22 | 23 | override fun getType(uri: Uri) = unsupported() 24 | 25 | override fun insert(uri: Uri, values: ContentValues?) = unsupported() 26 | 27 | override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 28 | unsupported() 29 | 30 | override fun update( 31 | uri: Uri, 32 | values: ContentValues?, 33 | selection: String?, 34 | selectionArgs: Array?, 35 | ) = unsupported() 36 | 37 | private fun unsupported(errorMessage: String? = null): Nothing = 38 | throw UnsupportedOperationException(errorMessage) 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/ListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.content.Context 4 | import android.graphics.Bitmap 5 | import android.graphics.Canvas 6 | import android.graphics.Color 7 | import android.graphics.Paint 8 | import android.graphics.drawable.BitmapDrawable 9 | import android.util.Log 10 | import android.view.KeyEvent 11 | import android.view.LayoutInflater 12 | import android.view.View 13 | import android.view.ViewGroup 14 | import android.view.ViewGroup.FOCUS_BEFORE_DESCENDANTS 15 | import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS 16 | import androidx.core.content.ContextCompat 17 | import androidx.core.view.marginStart 18 | import androidx.core.view.setPadding 19 | import androidx.recyclerview.widget.LinearLayoutManager 20 | import androidx.recyclerview.widget.RecyclerView 21 | import com.bumptech.glide.Glide 22 | import com.lizongying.mytv0.databinding.ListItemBinding 23 | import com.lizongying.mytv0.models.TVListModel 24 | import com.lizongying.mytv0.models.TVModel 25 | 26 | 27 | class ListAdapter( 28 | private val context: Context, 29 | private val recyclerView: RecyclerView, 30 | var listTVModel: TVListModel, 31 | ) : 32 | RecyclerView.Adapter() { 33 | private var listener: ItemListener? = null 34 | private var focused: View? = null 35 | private var defaultFocused = false 36 | private var defaultFocus: Int = -1 37 | 38 | var visiable = false 39 | 40 | val application = context.applicationContext as MyTVApplication 41 | 42 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 43 | val inflater = LayoutInflater.from(context) 44 | val binding = ListItemBinding.inflate(inflater, parent, false) 45 | 46 | binding.icon.layoutParams.width = application.px2Px(binding.icon.layoutParams.width) 47 | binding.icon.layoutParams.height = application.px2Px(binding.icon.layoutParams.height) 48 | binding.icon.setPadding(application.px2Px(binding.icon.paddingTop)) 49 | 50 | val layoutParams = binding.title.layoutParams as ViewGroup.MarginLayoutParams 51 | layoutParams.marginStart = application.px2Px(binding.title.marginStart) 52 | binding.title.layoutParams = layoutParams 53 | 54 | binding.heart.layoutParams.width = application.px2Px(binding.heart.layoutParams.width) 55 | binding.heart.layoutParams.height = application.px2Px(binding.heart.layoutParams.height) 56 | 57 | binding.title.textSize = application.px2PxFont(binding.title.textSize) 58 | 59 | val layoutParamsHeart = binding.heart.layoutParams as ViewGroup.MarginLayoutParams 60 | layoutParamsHeart.marginStart = application.px2Px(binding.heart.marginStart) 61 | binding.heart.layoutParams = layoutParamsHeart 62 | 63 | binding.description.textSize = application.px2PxFont(binding.description.textSize) 64 | 65 | return ViewHolder(context, binding) 66 | } 67 | 68 | fun focusable(able: Boolean) { 69 | recyclerView.isFocusable = able 70 | recyclerView.isFocusableInTouchMode = able 71 | if (able) { 72 | recyclerView.descendantFocusability = FOCUS_BEFORE_DESCENDANTS 73 | } else { 74 | recyclerView.descendantFocusability = FOCUS_BLOCK_DESCENDANTS 75 | } 76 | } 77 | 78 | fun update(listTVModel: TVListModel) { 79 | this.listTVModel = listTVModel 80 | recyclerView.post { 81 | notifyDataSetChanged() 82 | } 83 | } 84 | 85 | fun clear() { 86 | focused?.clearFocus() 87 | recyclerView.invalidate() 88 | } 89 | 90 | override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { 91 | val tvModel = listTVModel.getTVModel(position)!! 92 | val view = viewHolder.itemView 93 | 94 | view.isFocusable = true 95 | view.isFocusableInTouchMode = true 96 | // view.alpha = 0.8F 97 | 98 | viewHolder.like(tvModel.like.value as Boolean) 99 | 100 | viewHolder.binding.heart.setOnClickListener { 101 | tvModel.setLike(!(tvModel.like.value as Boolean)) 102 | viewHolder.like(tvModel.like.value as Boolean) 103 | } 104 | 105 | if (!defaultFocused && position == defaultFocus) { 106 | view.requestFocus() 107 | defaultFocused = true 108 | } 109 | 110 | val onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> 111 | listener?.onItemFocusChange(tvModel, hasFocus) 112 | 113 | if (hasFocus) { 114 | viewHolder.focus(true) 115 | focused = view 116 | if (visiable) { 117 | if (position != listTVModel.positionValue) { 118 | listTVModel.setPosition(position) 119 | } 120 | } else { 121 | visiable = true 122 | } 123 | } else { 124 | viewHolder.focus(false) 125 | } 126 | } 127 | 128 | view.onFocusChangeListener = onFocusChangeListener 129 | 130 | view.setOnClickListener { _ -> 131 | listener?.onItemClicked(position) 132 | } 133 | 134 | view.setOnKeyListener { _, keyCode, event: KeyEvent? -> 135 | if (event?.action == KeyEvent.ACTION_DOWN) { 136 | if (keyCode == KeyEvent.KEYCODE_DPAD_UP && position == 0) { 137 | val p = getItemCount() - 1 138 | 139 | (recyclerView.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset( 140 | p, 141 | 0 142 | ) 143 | 144 | recyclerView.postDelayed({ 145 | val v = recyclerView.findViewHolderForAdapterPosition(p) 146 | v?.itemView?.isSelected = true 147 | v?.itemView?.requestFocus() 148 | }, 0) 149 | } 150 | 151 | if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && position == getItemCount() - 1) { 152 | val p = 0 153 | 154 | (recyclerView.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset( 155 | p, 156 | 0 157 | ) 158 | 159 | recyclerView.postDelayed({ 160 | val v = recyclerView.findViewHolderForAdapterPosition(p) 161 | v?.itemView?.isSelected = true 162 | v?.itemView?.requestFocus() 163 | }, 0) 164 | } 165 | 166 | if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { 167 | tvModel.setLike(!(tvModel.like.value as Boolean)) 168 | viewHolder.like(tvModel.like.value as Boolean) 169 | } 170 | 171 | return@setOnKeyListener listener?.onKey(this, keyCode) ?: false 172 | } 173 | false 174 | } 175 | 176 | viewHolder.bindTitle(tvModel.tv.title) 177 | 178 | viewHolder.bindImage(tvModel.tv.logo, tvModel.tv.id) 179 | } 180 | 181 | override fun getItemCount() = listTVModel.size() 182 | 183 | class ViewHolder(private val context: Context, val binding: ListItemBinding) : 184 | RecyclerView.ViewHolder(binding.root) { 185 | fun bindTitle(text: String) { 186 | binding.title.text = text 187 | } 188 | 189 | fun bindImage(url: String?, id: Int) { 190 | val width = Utils.dpToPx(40) 191 | val height = Utils.dpToPx(40) 192 | val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) 193 | val canvas = Canvas(bitmap) 194 | 195 | val paint = Paint().apply { 196 | color = Color.WHITE 197 | textSize = 32f 198 | textAlign = Paint.Align.CENTER 199 | } 200 | val text = String.format("%3d", id + 1) 201 | val x = width / 2f 202 | val y = height / 2f - (paint.descent() + paint.ascent()) / 2 203 | canvas.drawText(text, x, y, paint) 204 | 205 | if (url.isNullOrBlank()) { 206 | Glide.with(context) 207 | .load(BitmapDrawable(context.resources, bitmap)) 208 | .centerInside() 209 | .into(binding.icon) 210 | // binding.imageView.setImageDrawable(null) 211 | } else { 212 | Glide.with(context) 213 | .load(url) 214 | .centerInside() 215 | .error(BitmapDrawable(context.resources, bitmap)) 216 | .into(binding.icon) 217 | } 218 | } 219 | 220 | fun focus(hasFocus: Boolean) { 221 | if (hasFocus) { 222 | binding.title.setTextColor(ContextCompat.getColor(context, R.color.white)) 223 | binding.description.setTextColor(ContextCompat.getColor(context, R.color.white)) 224 | binding.root.setBackgroundResource(R.color.focus) 225 | } else { 226 | binding.title.setTextColor(ContextCompat.getColor(context, R.color.title_blur)) 227 | binding.description.setTextColor( 228 | ContextCompat.getColor( 229 | context, 230 | R.color.description_blur 231 | ) 232 | ) 233 | binding.root.setBackgroundResource(R.color.blur) 234 | } 235 | } 236 | 237 | fun like(liked: Boolean) { 238 | if (liked) { 239 | binding.heart.setImageDrawable( 240 | ContextCompat.getDrawable( 241 | context, 242 | R.drawable.ic_heart 243 | ) 244 | ) 245 | } else { 246 | binding.heart.setImageDrawable( 247 | ContextCompat.getDrawable( 248 | context, 249 | R.drawable.ic_heart_empty 250 | ) 251 | ) 252 | } 253 | } 254 | } 255 | 256 | fun toPosition(position: Int) { 257 | Log.i(TAG, "position $position") 258 | recyclerView.post { 259 | (recyclerView.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset( 260 | position, 261 | 0 262 | ) 263 | 264 | recyclerView.postDelayed({ 265 | val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) 266 | viewHolder?.itemView?.isSelected = true 267 | viewHolder?.itemView?.requestFocus() 268 | }, 0) 269 | } 270 | } 271 | 272 | interface ItemListener { 273 | fun onItemFocusChange(tvModel: TVModel, hasFocus: Boolean) 274 | fun onItemClicked(position: Int, type: String = "list") 275 | fun onKey(listAdapter: ListAdapter, keyCode: Int): Boolean 276 | } 277 | 278 | fun setItemListener(listener: ItemListener) { 279 | this.listener = listener 280 | } 281 | 282 | companion object { 283 | private const val TAG = "ListAdapter" 284 | } 285 | } 286 | 287 | -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/LoadingFragment.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import com.lizongying.mytv0.databinding.LoadingBinding 9 | 10 | class LoadingFragment : Fragment() { 11 | private var _binding: LoadingBinding? = null 12 | private val binding get() = _binding!! 13 | 14 | override fun onCreateView( 15 | inflater: LayoutInflater, container: ViewGroup?, 16 | savedInstanceState: Bundle? 17 | ): View { 18 | _binding = LoadingBinding.inflate(inflater, container, false) 19 | 20 | val application = requireActivity().applicationContext as MyTVApplication 21 | 22 | binding.bar.layoutParams.width = application.px2Px(binding.bar.layoutParams.width) 23 | binding.bar.layoutParams.height = application.px2Px(binding.bar.layoutParams.height) 24 | 25 | return binding.root 26 | } 27 | 28 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 29 | super.onViewCreated(view, savedInstanceState) 30 | (activity as MainActivity).ready(TAG) 31 | } 32 | 33 | override fun onDestroyView() { 34 | super.onDestroyView() 35 | _binding = null 36 | } 37 | 38 | companion object { 39 | private const val TAG = "LoadingFragment" 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/LocaleContextWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.content.Context 4 | import android.content.ContextWrapper 5 | import android.os.Build 6 | import android.os.LocaleList 7 | import java.util.Locale 8 | 9 | class LocaleContextWrapper private constructor(base: Context) : ContextWrapper(base) { 10 | companion object { 11 | fun wrap(context: Context, newLocale: Locale): Context { 12 | val resources = context.resources 13 | val configuration = resources.configuration 14 | 15 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 16 | configuration.setLocale(newLocale) 17 | val localeList = LocaleList(newLocale) 18 | LocaleList.setDefault(localeList) 19 | configuration.setLocales(localeList) 20 | } else { 21 | configuration.locale = newLocale 22 | } 23 | 24 | val updatedContext = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { 25 | context.createConfigurationContext(configuration) 26 | } else { 27 | resources.updateConfiguration(configuration, resources.displayMetrics) 28 | context 29 | } 30 | 31 | // For KitKat and below, return the original context 32 | return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { 33 | updatedContext 34 | } else { 35 | LocaleContextWrapper(updatedContext) 36 | } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/MenuFragment.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import MainViewModel 4 | import android.os.Bundle 5 | import android.util.Log 6 | import android.view.KeyEvent 7 | import android.view.LayoutInflater 8 | import android.view.View 9 | import android.view.View.GONE 10 | import android.view.View.VISIBLE 11 | import android.view.ViewGroup 12 | import androidx.core.view.isVisible 13 | import androidx.fragment.app.Fragment 14 | import androidx.lifecycle.ViewModelProvider 15 | import androidx.recyclerview.widget.LinearLayoutManager 16 | import com.lizongying.mytv0.databinding.MenuBinding 17 | import com.lizongying.mytv0.models.TVListModel 18 | import com.lizongying.mytv0.models.TVModel 19 | 20 | class MenuFragment : Fragment(), GroupAdapter.ItemListener, ListAdapter.ItemListener { 21 | private var _binding: MenuBinding? = null 22 | private val binding get() = _binding!! 23 | 24 | private lateinit var groupAdapter: GroupAdapter 25 | private lateinit var listAdapter: ListAdapter 26 | 27 | private var groupWidth = 0 28 | private var listWidth = 0 29 | 30 | private lateinit var viewModel: MainViewModel 31 | 32 | override fun onCreateView( 33 | inflater: LayoutInflater, container: ViewGroup?, 34 | savedInstanceState: Bundle? 35 | ): View { 36 | 37 | _binding = MenuBinding.inflate(inflater, container, false) 38 | return binding.root 39 | } 40 | 41 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 42 | super.onViewCreated(view, savedInstanceState) 43 | val context = requireActivity() 44 | val application = requireActivity().applicationContext as MyTVApplication 45 | viewModel = ViewModelProvider(context)[MainViewModel::class.java] 46 | 47 | 48 | Log.i(TAG, "groupModel ${viewModel.groupModel}") 49 | groupAdapter = GroupAdapter( 50 | context, 51 | binding.group, 52 | viewModel.groupModel, 53 | ) 54 | binding.group.adapter = groupAdapter 55 | binding.group.layoutManager = 56 | LinearLayoutManager(context) 57 | groupWidth = application.px2Px(binding.group.layoutParams.width) 58 | binding.group.layoutParams.width = if (SP.compactMenu) { 59 | groupWidth * 2 / 3 60 | } else { 61 | groupWidth 62 | } 63 | groupAdapter.setItemListener(this) 64 | 65 | var listTVModel = 66 | viewModel.groupModel.getCurrentList() 67 | 68 | Log.i(TAG, "listTVModel0 ${viewModel.groupModel.positionValue} $listTVModel") 69 | if (listTVModel == null) { 70 | viewModel.groupModel.setPosition(0) 71 | } 72 | 73 | listTVModel = 74 | viewModel.groupModel.getCurrentList() 75 | Log.i(TAG, "listTVModel1 ${viewModel.groupModel.positionValue} $listTVModel") 76 | listAdapter = ListAdapter( 77 | context, 78 | binding.list, 79 | listTVModel!!, 80 | ) 81 | binding.list.adapter = listAdapter 82 | binding.list.layoutManager = 83 | LinearLayoutManager(context) 84 | listWidth = application.px2Px(binding.list.layoutParams.width) 85 | binding.list.layoutParams.width = if (SP.compactMenu) { 86 | listWidth * 4 / 5 87 | } else { 88 | listWidth 89 | } 90 | listAdapter.focusable(false) 91 | listAdapter.setItemListener(this) 92 | 93 | binding.menu.setOnClickListener { 94 | hideSelf() 95 | } 96 | (activity as MainActivity).ready(TAG) 97 | } 98 | 99 | fun update() { 100 | view?.post { 101 | groupAdapter.update(viewModel.groupModel) 102 | 103 | var listTVModel = 104 | viewModel.groupModel.getCurrentList() 105 | 106 | Log.i(TAG, "listTVModel3 ${viewModel.groupModel.positionValue} $listTVModel") 107 | if (listTVModel == null) { 108 | viewModel.groupModel.setPosition(0) 109 | } 110 | listTVModel = 111 | viewModel.groupModel.getCurrentList() 112 | 113 | Log.i(TAG, "listTVModel4 ${viewModel.groupModel.positionValue} $listTVModel") 114 | if (listTVModel != null) { 115 | (binding.list.adapter as ListAdapter).update(listTVModel) 116 | } 117 | } 118 | } 119 | 120 | fun updateSize() { 121 | view?.post { 122 | binding.group.layoutParams.width = if (SP.compactMenu) { 123 | groupWidth * 2 / 3 124 | } else { 125 | groupWidth 126 | } 127 | 128 | binding.list.layoutParams.width = if (SP.compactMenu) { 129 | listWidth * 4 / 5 130 | } else { 131 | listWidth 132 | } 133 | } 134 | } 135 | 136 | fun updateList(position: Int) { 137 | viewModel.groupModel.setPosition(position) 138 | SP.positionGroup = position 139 | 140 | viewModel.groupModel.getCurrentList()?.let { 141 | (binding.list.adapter as ListAdapter).update(it) 142 | } 143 | } 144 | 145 | private fun hideSelf() { 146 | requireActivity().supportFragmentManager.beginTransaction() 147 | .hide(this) 148 | .commit() 149 | } 150 | 151 | override fun onItemFocusChange(listTVModel: TVListModel, hasFocus: Boolean) { 152 | if (hasFocus) { 153 | (binding.list.adapter as ListAdapter).update(listTVModel) 154 | (activity as MainActivity).menuActive() 155 | } 156 | } 157 | 158 | override fun onItemFocusChange(tvModel: TVModel, hasFocus: Boolean) { 159 | if (hasFocus) { 160 | (activity as MainActivity).menuActive() 161 | } 162 | } 163 | 164 | override fun onItemClicked(position: Int) { 165 | // Log.i(TAG, "onItemClicked ${tvModel.tv.id} ${tvModel.tv.title}") 166 | // TVList.setPosition(tvModel.tv.id) 167 | // (activity as MainActivity).hideMenuFragment() 168 | } 169 | 170 | override fun onItemClicked(position: Int, type: String) { 171 | viewModel.groupModel.setPlaying() 172 | viewModel.groupModel.getCurrentList()?.let { 173 | it.setPosition(position) 174 | it.setPlaying() 175 | it.getCurrent()?.setReady() 176 | } 177 | (activity as MainActivity).hideMenuFragment() 178 | } 179 | 180 | override fun onKey(keyCode: Int): Boolean { 181 | when (keyCode) { 182 | KeyEvent.KEYCODE_DPAD_RIGHT -> { 183 | if (listAdapter.itemCount == 0) { 184 | R.string.channel_not_exist.showToast() 185 | return true 186 | } 187 | binding.group.visibility = GONE 188 | groupAdapter.focusable(false) 189 | listAdapter.focusable(true) 190 | 191 | if (viewModel.groupModel.positionPlayingValue == viewModel.groupModel.positionValue) { 192 | viewModel.groupModel.getCurrentList()?.let { 193 | listAdapter.toPosition(it.positionPlayingValue) 194 | } 195 | } else { 196 | listAdapter.toPosition(0) 197 | } 198 | 199 | return true 200 | } 201 | 202 | KeyEvent.KEYCODE_DPAD_LEFT -> { 203 | // (activity as MainActivity).hideMenuFragment() 204 | return true 205 | } 206 | } 207 | return false 208 | } 209 | 210 | override fun onKey(listAdapter: ListAdapter, keyCode: Int): Boolean { 211 | when (keyCode) { 212 | KeyEvent.KEYCODE_DPAD_LEFT -> { 213 | binding.group.visibility = VISIBLE 214 | groupAdapter.focusable(true) 215 | listAdapter.focusable(false) 216 | listAdapter.clear() 217 | Log.i(TAG, "group toPosition on left") 218 | groupAdapter.scrollToPositionAndSelect(viewModel.groupModel.positionValue) 219 | return true 220 | } 221 | // KeyEvent.KEYCODE_DPAD_RIGHT -> { 222 | // binding.group.visibility = VISIBLE 223 | // groupAdapter.focusable(true) 224 | // listAdapter.focusable(false) 225 | // listAdapter.clear() 226 | // Log.i(TAG, "group toPosition on left") 227 | // groupAdapter.toPosition(TVList.groupModel.positionValue) 228 | // return true 229 | // } 230 | } 231 | return false 232 | } 233 | 234 | override fun onHiddenChanged(hidden: Boolean) { 235 | super.onHiddenChanged(hidden) 236 | if (!hidden) { 237 | if (binding.list.isVisible) { 238 | // if (binding.group.isVisible) { 239 | // groupAdapter.focusable(true) 240 | // listAdapter.focusable(false) 241 | // } else { 242 | // groupAdapter.focusable(false) 243 | // listAdapter.focusable(true) 244 | // } 245 | 246 | if (viewModel.groupModel.tvGroupValue.size < 2 || viewModel.groupModel.getAllList()?.size() == 0 247 | ) { 248 | R.string.channel_not_exist.showToast() 249 | return 250 | } 251 | 252 | val position = viewModel.groupModel.positionPlayingValue 253 | if (position != viewModel.groupModel.positionValue 254 | ) { 255 | updateList(position) 256 | } 257 | viewModel.groupModel.getCurrentList()?.let { 258 | listAdapter.toPosition(it.positionPlayingValue) 259 | } 260 | } 261 | if (binding.group.isVisible) { 262 | // groupAdapter.focusable(true) 263 | // listAdapter.focusable(false) 264 | 265 | val position = viewModel.groupModel.positionPlayingValue 266 | Log.i(TAG, "group position $position/${viewModel.groupModel.tvGroupValue.size}") 267 | if (position != viewModel.groupModel.positionValue) { 268 | viewModel.groupModel.setPosition(position) 269 | } 270 | groupAdapter.scrollToPositionAndSelect(position) 271 | } 272 | (activity as MainActivity).menuActive() 273 | } else { 274 | view?.post { 275 | groupAdapter.visiable = false 276 | listAdapter.visiable = false 277 | } 278 | } 279 | } 280 | 281 | override fun onDestroyView() { 282 | super.onDestroyView() 283 | _binding = null 284 | } 285 | 286 | companion object { 287 | private const val TAG = "MenuFragment" 288 | } 289 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/ModalFragment.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.graphics.Bitmap 4 | import android.os.Bundle 5 | import android.os.Handler 6 | import android.os.Looper 7 | import android.view.LayoutInflater 8 | import android.view.View 9 | import android.view.ViewGroup 10 | import android.view.WindowManager 11 | import androidx.fragment.app.DialogFragment 12 | import com.bumptech.glide.Glide 13 | import com.lizongying.mytv0.databinding.ModalBinding 14 | 15 | 16 | class ModalFragment : DialogFragment() { 17 | 18 | private var _binding: ModalBinding? = null 19 | private val binding get() = _binding!! 20 | 21 | private val handler = Handler(Looper.myLooper()!!) 22 | private val delayHideAppreciateModal = 10000L 23 | 24 | override fun onStart() { 25 | super.onStart() 26 | dialog?.window?.apply { 27 | addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) 28 | decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION 29 | } 30 | } 31 | 32 | override fun onCreateView( 33 | inflater: LayoutInflater, 34 | container: ViewGroup?, 35 | savedInstanceState: Bundle? 36 | ): View { 37 | _binding = ModalBinding.inflate(inflater, container, false) 38 | return binding.root 39 | } 40 | 41 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 42 | super.onViewCreated(view, savedInstanceState) 43 | 44 | val bitmap: Bitmap? = arguments?.getParcelable(KEY_BITMAP) 45 | 46 | if (bitmap != null) { 47 | Glide.with(requireContext()) 48 | .load(bitmap) 49 | .into(binding.modalImage) 50 | } else { 51 | Glide.with(requireContext()) 52 | .load(arguments?.getInt(KEY_DRAWABLE_ID)) 53 | .into(binding.modalImage) 54 | } 55 | 56 | handler.postDelayed(hideAppreciateModal, delayHideAppreciateModal) 57 | } 58 | 59 | private val hideAppreciateModal = Runnable { 60 | if (!this.isHidden) { 61 | this.dismiss() 62 | } 63 | } 64 | 65 | override fun onDestroyView() { 66 | super.onDestroyView() 67 | _binding = null 68 | handler.removeCallbacksAndMessages(null) 69 | } 70 | 71 | companion object { 72 | const val KEY_DRAWABLE_ID = "drawable_id" 73 | const val KEY_BITMAP = "bitmap" 74 | const val TAG = "ModalFragment" 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/MyTVApplication.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.content.Context 4 | import android.content.res.Resources 5 | import android.os.Handler 6 | import android.os.Looper 7 | import android.util.DisplayMetrics 8 | import android.view.WindowManager 9 | import android.widget.Toast 10 | import androidx.multidex.MultiDexApplication 11 | import java.util.Locale 12 | 13 | class MyTVApplication : MultiDexApplication() { 14 | 15 | companion object { 16 | private const val TAG = "MyTVApplication" 17 | private lateinit var instance: MyTVApplication 18 | 19 | @JvmStatic 20 | fun getInstance(): MyTVApplication { 21 | return instance 22 | } 23 | } 24 | 25 | private lateinit var displayMetrics: DisplayMetrics 26 | private lateinit var realDisplayMetrics: DisplayMetrics 27 | 28 | private var width = 0 29 | private var height = 0 30 | private var shouldWidth = 0 31 | private var shouldHeight = 0 32 | private var ratio = 1.0 33 | private var density = 2.0f 34 | private var scale = 1.0f 35 | 36 | override fun onCreate() { 37 | super.onCreate() 38 | instance = this 39 | 40 | displayMetrics = DisplayMetrics() 41 | realDisplayMetrics = DisplayMetrics() 42 | val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager 43 | windowManager.defaultDisplay.getMetrics(displayMetrics) 44 | windowManager.defaultDisplay.getRealMetrics(realDisplayMetrics) 45 | 46 | if (realDisplayMetrics.heightPixels > realDisplayMetrics.widthPixels) { 47 | width = realDisplayMetrics.heightPixels 48 | height = realDisplayMetrics.widthPixels 49 | } else { 50 | width = realDisplayMetrics.widthPixels 51 | height = realDisplayMetrics.heightPixels 52 | } 53 | 54 | density = Resources.getSystem().displayMetrics.density 55 | scale = displayMetrics.scaledDensity 56 | 57 | if ((width.toDouble() / height) < (16.0 / 9.0)) { 58 | ratio = width * 2 / 1920.0 / density 59 | shouldWidth = width 60 | shouldHeight = (width * 9.0 / 16.0).toInt() 61 | } else { 62 | ratio = height * 2 / 1080.0 / density 63 | shouldHeight = height 64 | shouldWidth = (height * 16.0 / 9.0).toInt() 65 | } 66 | 67 | Thread.setDefaultUncaughtExceptionHandler(MyTVExceptionHandler(this)) 68 | } 69 | 70 | fun getDisplayMetrics(): DisplayMetrics { 71 | return displayMetrics 72 | } 73 | 74 | fun toast(message: CharSequence = "", duration: Int = Toast.LENGTH_SHORT) { 75 | Handler(Looper.getMainLooper()).post { 76 | Toast.makeText(applicationContext, message, duration).show() 77 | } 78 | } 79 | 80 | fun shouldWidthPx(): Int { 81 | return shouldWidth 82 | } 83 | 84 | fun shouldHeightPx(): Int { 85 | return shouldHeight 86 | } 87 | 88 | fun dp2Px(dp: Int): Int { 89 | return (dp * ratio * density + 0.5f).toInt() 90 | } 91 | 92 | fun px2Px(px: Int): Int { 93 | return (px * ratio + 0.5f).toInt() 94 | } 95 | 96 | fun px2PxFont(px: Float): Float { 97 | return (px * ratio / scale).toFloat() 98 | } 99 | 100 | fun sp2Px(sp: Float): Float { 101 | return (sp * ratio * scale).toFloat() 102 | } 103 | 104 | override fun attachBaseContext(base: Context) { 105 | //Locale.SIMPLIFIED_CHINESE 106 | //Locale.TRADITIONAL_CHINESE 107 | val locale = Locale.TRADITIONAL_CHINESE 108 | val context = LocaleContextWrapper.wrap(base, locale) 109 | super.attachBaseContext(context) 110 | } 111 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/MyTVExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.util.Log 6 | import com.lizongying.mytv0.requests.HttpClient 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.runBlocking 10 | import kotlinx.coroutines.withContext 11 | import okhttp3.MediaType 12 | import okhttp3.Request 13 | import okhttp3.RequestBody 14 | import java.io.IOException 15 | import kotlin.system.exitProcess 16 | 17 | class MyTVExceptionHandler(private val context: Context) : Thread.UncaughtExceptionHandler { 18 | override fun uncaughtException(t: Thread, e: Throwable) { 19 | val crashInfo = 20 | "APP: ${context.appVersionName}, PRODUCT: ${Build.PRODUCT}, DEVICE: ${Build.DEVICE}, SUPPORTED_ABIS: ${Build.CPU_ABI},${Build.CPU_ABI2}, BOARD: ${Build.BOARD}, MANUFACTURER: ${Build.MANUFACTURER}, MODEL: ${Build.MODEL}, VERSION: ${Build.VERSION.SDK_INT}\nThread: ${t.name}\nException: ${e.message}\nStackTrace: ${ 21 | Log.getStackTraceString( 22 | e 23 | ) 24 | }\n" 25 | 26 | runBlocking { 27 | launch(Dispatchers.IO) { 28 | saveCrashInfoToFile(crashInfo) 29 | 30 | withContext(Dispatchers.Main) { 31 | android.os.Process.killProcess(android.os.Process.myPid()) 32 | exitProcess(1) 33 | } 34 | } 35 | } 36 | } 37 | 38 | private suspend fun saveCrashInfoToFile(crashInfo: String) { 39 | if (isLimit()) { 40 | Log.e(TAG, crashInfo) 41 | } else { 42 | try { 43 | saveLog(crashInfo) 44 | } catch (e: Exception) { 45 | e.printStackTrace() 46 | } 47 | } 48 | } 49 | 50 | private fun isLimit(): Boolean { 51 | if (context.appVersionName != SP.version) { 52 | SP.version = context.appVersionName 53 | SP.logTimes = SP.DEFAULT_LOG_TIMES 54 | return false 55 | } else { 56 | SP.logTimes-- 57 | return SP.logTimes < 0 58 | } 59 | } 60 | 61 | private suspend fun saveLog(crashInfo: String) { 62 | withContext(Dispatchers.IO) { 63 | 64 | val requestBody = RequestBody.create(MediaType.parse("text/plain"), crashInfo) 65 | val request = Request.Builder() 66 | .url("https://lyrics.run/my-tv-0/v1/log") 67 | .post(requestBody) 68 | .build() 69 | try { 70 | HttpClient.okHttpClient.newCall(request).execute().use { response -> 71 | if (response.isSuccessful) { 72 | Log.i(TAG, "log success") 73 | } else { 74 | Log.e(TAG, "log failed: ${response.code()}") 75 | } 76 | } 77 | } catch (e: IOException) { 78 | e.printStackTrace() 79 | } 80 | } 81 | } 82 | 83 | companion object { 84 | private const val TAG = "MyTVException" 85 | } 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/PortUtil.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import java.io.IOException 4 | import java.net.Inet4Address 5 | import java.net.NetworkInterface 6 | import java.net.ServerSocket 7 | 8 | object PortUtil { 9 | 10 | fun findFreePort(): Int { 11 | return try { 12 | ServerSocket(10086).use { socket -> 13 | socket.localPort 14 | } 15 | } catch (e: IOException) { 16 | try { 17 | ServerSocket(0).use { socket -> 18 | socket.localPort 19 | } 20 | } catch (e: IOException) { 21 | e.printStackTrace() 22 | -1 // Return -1 to indicate an error 23 | } 24 | } 25 | } 26 | 27 | fun lan(): String? { 28 | val networkInterfaces = NetworkInterface.getNetworkInterfaces() 29 | while (networkInterfaces.hasMoreElements()) { 30 | val inetAddresses = networkInterfaces.nextElement().inetAddresses 31 | while (inetAddresses.hasMoreElements()) { 32 | val inetAddress = inetAddresses.nextElement() 33 | if (inetAddress is Inet4Address) { 34 | if (inetAddress.hostAddress == "127.0.0.1") { 35 | continue 36 | } 37 | return inetAddress.hostAddress 38 | } 39 | } 40 | } 41 | return null 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/QrCodeUtil.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.graphics.Bitmap 4 | import android.graphics.Color 5 | import androidx.annotation.ColorInt 6 | import com.google.zxing.BarcodeFormat 7 | import com.google.zxing.EncodeHintType 8 | import com.google.zxing.WriterException 9 | import com.google.zxing.qrcode.QRCodeWriter 10 | import java.util.Hashtable 11 | 12 | class QrCodeUtil { 13 | 14 | fun createQRCodeBitmap( 15 | content: String, 16 | width: Int, 17 | height: Int, 18 | characterSet: String = "UTF-8", 19 | errorCorrection: String = "L", 20 | margin: String = "1", 21 | @ColorInt colorBlack: Int = Color.BLACK, 22 | @ColorInt colorWhite: Int = Color.WHITE, 23 | ): Bitmap? { 24 | if (width < 0 || height < 0) { 25 | return null 26 | } 27 | try { 28 | val hints: Hashtable = Hashtable() 29 | if (characterSet.isNotEmpty()) { 30 | hints[EncodeHintType.CHARACTER_SET] = characterSet 31 | } 32 | if (errorCorrection.isNotEmpty()) { 33 | hints[EncodeHintType.ERROR_CORRECTION] = errorCorrection 34 | } 35 | if (margin.isNotEmpty()) { 36 | hints[EncodeHintType.MARGIN] = margin 37 | } 38 | val bitMatrix = 39 | QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints) 40 | 41 | val pixels = IntArray(width * height) 42 | for (y in 0 until height) { 43 | for (x in 0 until width) { 44 | if (bitMatrix[x, y]) { 45 | pixels[y * width + x] = colorBlack 46 | } else { 47 | pixels[y * width + x] = colorWhite 48 | } 49 | } 50 | } 51 | val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) 52 | bitmap.setPixels(pixels, 0, width, 0, 0, width, height) 53 | return bitmap 54 | } catch (e: WriterException) { 55 | e.printStackTrace() 56 | } 57 | return null 58 | } 59 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/SP.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | 4 | import android.content.Context 5 | import android.content.SharedPreferences 6 | 7 | object SP { 8 | // If Change channel with up and down in reversed order or not 9 | private const val KEY_CHANNEL_REVERSAL = "channel_reversal" 10 | 11 | // If use channel num to select channel or not 12 | private const val KEY_CHANNEL_NUM = "channel_num" 13 | 14 | private const val KEY_TIME = "time" 15 | 16 | // If start app on device boot or not 17 | private const val KEY_BOOT_STARTUP = "boot_startup" 18 | 19 | // Position in list of the selected channel item 20 | private const val KEY_POSITION = "position" 21 | 22 | private const val KEY_POSITION_GROUP = "position_group" 23 | 24 | private const val KEY_POSITION_SUB = "position_sub" 25 | 26 | private const val KEY_REPEAT_INFO = "repeat_info" 27 | 28 | private const val KEY_CONFIG = "config" 29 | 30 | private const val KEY_CONFIG_AUTO_LOAD = "config_auto_load" 31 | 32 | private const val KEY_CHANNEL = "channel" 33 | 34 | private const val KEY_DEFAULT_LIKE = "default_like" 35 | 36 | private const val KEY_DISPLAY_SECONDS = "display_seconds" 37 | 38 | private const val KEY_SHOW_ALL_CHANNELS = "show_all_channels" 39 | 40 | private const val KEY_COMPACT_MENU = "compact_menu" 41 | 42 | private const val KEY_LIKE = "like" 43 | 44 | private const val KEY_PROXY = "proxy" 45 | 46 | private const val KEY_EPG = "epg" 47 | 48 | private const val KEY_VERSION = "version" 49 | 50 | private const val KEY_LOG_TIMES = "log_times" 51 | 52 | const val DEFAULT_CONFIG_URL = "https://mirror.ghproxy.com/https://raw.githubusercontent.com/YueChan/Live/main/IPTV.m3u" 53 | const val DEFAULT_EPG = "https://live.fanmingming.com/e.xml" 54 | const val DEFAULT_CHANNEL = 0 55 | const val DEFAULT_SHOW_ALL_CHANNELS = false 56 | const val DEFAULT_COMPACT_MENU = true 57 | const val DEFAULT_DISPLAY_SECONDS = false 58 | const val DEFAULT_LOG_TIMES = 10 59 | const val DEFAULT_POSITION_GROUP = 1 60 | const val DEFAULT_POSITION = 0 61 | 62 | private const val KEY_CHANNEL_LIST_JSON = "channel_list_json" 63 | 64 | private lateinit var sp: SharedPreferences 65 | 66 | /** 67 | * The method must be invoked as early as possible(At least before using the keys) 68 | */ 69 | fun init(context: Context) { 70 | sp = context.getSharedPreferences( 71 | context.getString(R.string.app_name), 72 | Context.MODE_PRIVATE 73 | ) 74 | } 75 | 76 | var channelReversal: Boolean 77 | get() = sp.getBoolean(KEY_CHANNEL_REVERSAL, false) 78 | set(value) = sp.edit().putBoolean(KEY_CHANNEL_REVERSAL, value).apply() 79 | 80 | var channelNum: Boolean 81 | get() = sp.getBoolean(KEY_CHANNEL_NUM, true) 82 | set(value) = sp.edit().putBoolean(KEY_CHANNEL_NUM, value).apply() 83 | 84 | var time: Boolean 85 | get() = sp.getBoolean(KEY_TIME, true) 86 | set(value) = sp.edit().putBoolean(KEY_TIME, value).apply() 87 | 88 | var bootStartup: Boolean 89 | get() = sp.getBoolean(KEY_BOOT_STARTUP, false) 90 | set(value) = sp.edit().putBoolean(KEY_BOOT_STARTUP, value).apply() 91 | 92 | var positionGroup: Int 93 | get() = sp.getInt(KEY_POSITION_GROUP, DEFAULT_POSITION_GROUP) 94 | set(value) = sp.edit().putInt(KEY_POSITION_GROUP, value).apply() 95 | 96 | var position: Int 97 | get() = sp.getInt(KEY_POSITION, DEFAULT_POSITION) 98 | set(value) = sp.edit().putInt(KEY_POSITION, value).apply() 99 | 100 | var positionSub: Int 101 | get() = sp.getInt(KEY_POSITION_SUB, 0) 102 | set(value) = sp.edit().putInt(KEY_POSITION_SUB, value).apply() 103 | 104 | var repeatInfo: Boolean 105 | get() = sp.getBoolean(KEY_REPEAT_INFO, true) 106 | set(value) = sp.edit().putBoolean(KEY_REPEAT_INFO, value).apply() 107 | 108 | var config: String? 109 | get() = sp.getString(KEY_CONFIG, DEFAULT_CONFIG_URL) 110 | set(value) = sp.edit().putString(KEY_CONFIG, value).apply() 111 | 112 | var configAutoLoad: Boolean 113 | get() = sp.getBoolean(KEY_CONFIG_AUTO_LOAD, false) 114 | set(value) = sp.edit().putBoolean(KEY_CONFIG_AUTO_LOAD, value).apply() 115 | 116 | var channel: Int 117 | get() = sp.getInt(KEY_CHANNEL, DEFAULT_CHANNEL) 118 | set(value) = sp.edit().putInt(KEY_CHANNEL, value).apply() 119 | 120 | var compactMenu: Boolean 121 | get() = sp.getBoolean(KEY_COMPACT_MENU, DEFAULT_COMPACT_MENU) 122 | set(value) = sp.edit().putBoolean(KEY_COMPACT_MENU, value).apply() 123 | 124 | var showAllChannels: Boolean 125 | get() = sp.getBoolean(KEY_SHOW_ALL_CHANNELS, DEFAULT_SHOW_ALL_CHANNELS) 126 | set(value) = sp.edit().putBoolean(KEY_SHOW_ALL_CHANNELS, value).apply() 127 | 128 | var defaultLike: Boolean 129 | get() = sp.getBoolean(KEY_DEFAULT_LIKE, false) 130 | set(value) = sp.edit().putBoolean(KEY_DEFAULT_LIKE, value).apply() 131 | 132 | var displaySeconds: Boolean 133 | get() = sp.getBoolean(KEY_DISPLAY_SECONDS, DEFAULT_DISPLAY_SECONDS) 134 | set(value) = sp.edit().putBoolean(KEY_DISPLAY_SECONDS, value).apply() 135 | 136 | fun getLike(id: Int): Boolean { 137 | val stringSet = sp.getStringSet(KEY_LIKE, emptySet()) 138 | return stringSet?.contains(id.toString()) ?: false 139 | } 140 | 141 | fun setLike(id: Int, liked: Boolean) { 142 | val stringSet = sp.getStringSet(KEY_LIKE, emptySet())?.toMutableSet() ?: mutableSetOf() 143 | if (liked) { 144 | stringSet.add(id.toString()) 145 | } else { 146 | stringSet.remove(id.toString()) 147 | } 148 | 149 | sp.edit().putStringSet(KEY_LIKE, stringSet).apply() 150 | } 151 | 152 | fun deleteLike() { 153 | sp.edit().remove(KEY_LIKE).apply() 154 | } 155 | 156 | var proxy: String? 157 | get() = sp.getString(KEY_PROXY, "") 158 | set(value) = sp.edit().putString(KEY_PROXY, value).apply() 159 | 160 | var epg: String? 161 | get() = sp.getString(KEY_EPG, DEFAULT_EPG) 162 | set(value) = sp.edit().putString(KEY_EPG, value).apply() 163 | 164 | var version: String? 165 | get() = sp.getString(KEY_VERSION, "") 166 | set(value) = sp.edit().putString(KEY_VERSION, value).apply() 167 | 168 | var logTimes: Int 169 | get() = sp.getInt(KEY_LOG_TIMES, DEFAULT_LOG_TIMES) 170 | set(value) = sp.edit().putInt(KEY_LOG_TIMES, value).apply() 171 | 172 | var channelListJson: String? 173 | get() = sp.getString(KEY_CHANNEL_LIST_JSON, "") 174 | set(value) = sp.edit().putString(KEY_CHANNEL_LIST_JSON, value).apply() 175 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/SimpleServer.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | 4 | import MainViewModel 5 | import android.content.Context 6 | import android.net.Uri 7 | import android.os.Handler 8 | import android.os.Looper 9 | import android.util.Log 10 | import com.google.gson.Gson 11 | import fi.iki.elonen.NanoHTTPD 12 | import java.io.File 13 | import java.io.IOException 14 | import java.nio.charset.StandardCharsets 15 | 16 | 17 | class SimpleServer(private val context: Context, private val viewModel: MainViewModel) : 18 | NanoHTTPD(PORT) { 19 | private val handler = Handler(Looper.getMainLooper()) 20 | 21 | init { 22 | try { 23 | start() 24 | val host = PortUtil.lan() 25 | (context as MainActivity).setServer("$host:$PORT") 26 | } catch (e: Exception) { 27 | e.printStackTrace() 28 | } 29 | } 30 | 31 | override fun serve(session: IHTTPSession): Response { 32 | return when (session.uri) { 33 | "/api/settings" -> handleSettings() 34 | "/api/channels" -> handleChannelsFromFile(session) 35 | "/api/uri" -> handleChannelsFromUri(session) 36 | "/api/proxy" -> handleProxy(session) 37 | "/api/epg" -> handleEPG(session) 38 | "/api/channel" -> handleDefaultChannel(session) 39 | else -> handleStaticContent(session) 40 | } 41 | } 42 | 43 | data class RespSettings( 44 | val channelUri: String, 45 | val channelDefault: Int, 46 | val proxy: String, 47 | val epg: String, 48 | ) 49 | 50 | private fun handleSettings(): Response { 51 | val response: String 52 | try { 53 | val respSettings = RespSettings( 54 | channelUri = SP.config ?: "", 55 | channelDefault = SP.channel, 56 | proxy = SP.proxy ?: "", 57 | epg = SP.epg ?: "", 58 | ) 59 | response = Gson().toJson(respSettings) ?: "" 60 | } catch (e: Exception) { 61 | e.printStackTrace() 62 | return newFixedLengthResponse( 63 | Response.Status.INTERNAL_ERROR, 64 | MIME_PLAINTEXT, 65 | e.message 66 | ) 67 | } 68 | 69 | return newFixedLengthResponse(Response.Status.OK, "application/json", response) 70 | } 71 | 72 | data class Req( 73 | var uri: String? = "", 74 | val proxy: String?, 75 | val epg: String?, 76 | val channel: Int?, 77 | ) 78 | 79 | private fun handleChannelsFromFile(session: IHTTPSession): Response { 80 | R.string.start_config_channel.showToast() 81 | val response = "" 82 | try { 83 | readBody(session)?.let { 84 | handler.post { 85 | if (viewModel.str2List(it)) { 86 | File(context.filesDir, viewModel.FILE_NAME).writeText(it) 87 | SP.config = "file://" 88 | R.string.channel_import_success.showToast() 89 | } else { 90 | R.string.channel_import_error.showToast() 91 | } 92 | } 93 | } 94 | } catch (e: Exception) { 95 | e.printStackTrace() 96 | return newFixedLengthResponse( 97 | Response.Status.INTERNAL_ERROR, 98 | MIME_PLAINTEXT, 99 | e.message 100 | ) 101 | } 102 | return newFixedLengthResponse(Response.Status.OK, "text/plain", response) 103 | } 104 | 105 | private fun handleChannelsFromUri(session: IHTTPSession): Response { 106 | R.string.start_config_channel.showToast() 107 | val response = "" 108 | try { 109 | readBody(session)?.let { 110 | val req = Gson().fromJson(it, Req::class.java) 111 | if (req.uri != null) { 112 | val url = req.uri 113 | val uri = Uri.parse(url) 114 | Log.i(TAG, "uri $uri") 115 | handler.post { 116 | viewModel.parseUri(uri) 117 | } 118 | } 119 | } 120 | } catch (e: IOException) { 121 | return newFixedLengthResponse( 122 | Response.Status.INTERNAL_ERROR, 123 | MIME_PLAINTEXT, 124 | e.message 125 | ) 126 | } 127 | return newFixedLengthResponse(Response.Status.OK, "text/plain", response) 128 | } 129 | 130 | private fun handleProxy(session: IHTTPSession): Response { 131 | try { 132 | readBody(session)?.let { 133 | handler.post { 134 | val req = Gson().fromJson(it, Req::class.java) 135 | if (req.proxy != null) { 136 | SP.proxy = req.proxy 137 | R.string.default_proxy_set_success.showToast() 138 | } else { 139 | R.string.default_proxy_set_failure.showToast() 140 | } 141 | } 142 | } 143 | } catch (e: Exception) { 144 | e.printStackTrace() 145 | return newFixedLengthResponse( 146 | Response.Status.INTERNAL_ERROR, 147 | MIME_PLAINTEXT, 148 | e.message 149 | ) 150 | } 151 | val response = "" 152 | return newFixedLengthResponse(Response.Status.OK, "text/plain", response) 153 | } 154 | 155 | private fun handleEPG(session: IHTTPSession): Response { 156 | try { 157 | readBody(session)?.let { 158 | handler.post { 159 | val req = Gson().fromJson(it, Req::class.java) 160 | if (req.epg != null) { 161 | SP.epg = req.epg 162 | R.string.default_epg_set_success.showToast() 163 | } else { 164 | R.string.default_epg_set_failure.showToast() 165 | } 166 | } 167 | } 168 | } catch (e: Exception) { 169 | e.printStackTrace() 170 | return newFixedLengthResponse( 171 | Response.Status.INTERNAL_ERROR, 172 | MIME_PLAINTEXT, 173 | e.message 174 | ) 175 | } 176 | val response = "" 177 | return newFixedLengthResponse(Response.Status.OK, "text/plain", response) 178 | } 179 | 180 | private fun handleDefaultChannel(session: IHTTPSession): Response { 181 | R.string.start_set_default_channel.showToast() 182 | val response = "" 183 | try { 184 | readBody(session)?.let { 185 | handler.post { 186 | val req = Gson().fromJson(it, Req::class.java) 187 | if (req.channel != null && req.channel > -1) { 188 | SP.channel = req.channel 189 | R.string.default_channel_set_success.showToast() 190 | } else { 191 | R.string.default_channel_set_failure.showToast() 192 | } 193 | } 194 | } 195 | } catch (e: Exception) { 196 | e.printStackTrace() 197 | return newFixedLengthResponse( 198 | Response.Status.INTERNAL_ERROR, 199 | MIME_PLAINTEXT, 200 | e.message 201 | ) 202 | } 203 | return newFixedLengthResponse(Response.Status.OK, "text/plain", response) 204 | } 205 | 206 | private fun readBody(session: IHTTPSession): String? { 207 | val map = HashMap() 208 | session.parseBody(map) 209 | return map["postData"] 210 | } 211 | 212 | private fun handleStaticContent(session: IHTTPSession): Response { 213 | val html = loadHtmlFromResource(R.raw.index) 214 | return newFixedLengthResponse(Response.Status.OK, "text/html", html) 215 | } 216 | 217 | private fun loadHtmlFromResource(resourceId: Int): String { 218 | val inputStream = context.resources.openRawResource(resourceId) 219 | return inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } 220 | } 221 | 222 | companion object { 223 | const val TAG = "SimpleServer" 224 | const val PORT = 34567 225 | } 226 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/TimeFragment.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import MainViewModel 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.core.view.marginEnd 9 | import androidx.core.view.marginTop 10 | import androidx.fragment.app.Fragment 11 | import androidx.lifecycle.ViewModelProvider 12 | import com.lizongying.mytv0.databinding.TimeBinding 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.Job 16 | import kotlinx.coroutines.delay 17 | import kotlinx.coroutines.isActive 18 | import kotlinx.coroutines.launch 19 | 20 | class TimeFragment : Fragment() { 21 | private var _binding: TimeBinding? = null 22 | private val binding get() = _binding!! 23 | 24 | private val delay: Long = 1000 25 | 26 | private var job: Job? = null 27 | 28 | private lateinit var viewModel: MainViewModel 29 | 30 | override fun onCreateView( 31 | inflater: LayoutInflater, container: ViewGroup?, 32 | savedInstanceState: Bundle? 33 | ): View { 34 | _binding = TimeBinding.inflate(inflater, container, false) 35 | 36 | val application = requireActivity().applicationContext as MyTVApplication 37 | 38 | binding.time.layoutParams.width = application.px2Px(binding.time.layoutParams.width) 39 | binding.time.layoutParams.height = application.px2Px(binding.time.layoutParams.height) 40 | 41 | val layoutParams = binding.time.layoutParams as ViewGroup.MarginLayoutParams 42 | layoutParams.topMargin = application.px2Px(binding.time.marginTop) 43 | layoutParams.marginEnd = application.px2Px(binding.time.marginEnd) 44 | binding.time.layoutParams = layoutParams 45 | 46 | binding.content.textSize = application.px2PxFont(binding.content.textSize) 47 | binding.channel.textSize = application.px2PxFont(binding.channel.textSize) 48 | 49 | binding.main.layoutParams.width = application.shouldWidthPx() 50 | binding.main.layoutParams.height = application.shouldHeightPx() 51 | 52 | return binding.root 53 | } 54 | 55 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 56 | super.onViewCreated(view, savedInstanceState) 57 | viewModel = ViewModelProvider(requireActivity())[MainViewModel::class.java] 58 | } 59 | 60 | override fun onHiddenChanged(hidden: Boolean) { 61 | super.onHiddenChanged(hidden) 62 | if (!hidden) { 63 | job = CoroutineScope(Dispatchers.Main).launch { 64 | while (isActive) { 65 | binding.content.text = viewModel.getTime() 66 | delay(delay) 67 | } 68 | } 69 | } else { 70 | job?.cancel() 71 | } 72 | } 73 | 74 | override fun onDestroyView() { 75 | super.onDestroyView() 76 | _binding = null 77 | job?.cancel() 78 | } 79 | 80 | companion object { 81 | private const val TAG = "TimeFragment" 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/UpdateManager.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Environment 8 | import android.util.Log 9 | import androidx.fragment.app.FragmentActivity 10 | import com.lizongying.mytv0.requests.HttpClient 11 | import com.lizongying.mytv0.requests.ReleaseRequest 12 | import com.lizongying.mytv0.requests.ReleaseResponse 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.GlobalScope 16 | import kotlinx.coroutines.Job 17 | import kotlinx.coroutines.delay 18 | import kotlinx.coroutines.launch 19 | import kotlinx.coroutines.withContext 20 | import java.io.File 21 | import java.io.IOException 22 | 23 | class UpdateManager( 24 | private val context: Context, 25 | private val versionCode: Long 26 | ) : ConfirmationFragment.ConfirmationListener { 27 | 28 | private var releaseRequest = ReleaseRequest() 29 | private var release: ReleaseResponse? = null 30 | private val okHttpClient = HttpClient.okHttpClient 31 | private var downloadJob: Job? = null 32 | private var lastLoggedProgress = -1 33 | 34 | fun checkAndUpdate() { 35 | Log.i(TAG, "checkAndUpdate") 36 | CoroutineScope(Dispatchers.Main).launch { 37 | var text = "版本获取失败" 38 | var update = false 39 | try { 40 | release = releaseRequest.getRelease() 41 | Log.i(TAG, "versionCode $versionCode ${release?.version_code}") 42 | if (release?.version_code != null) { 43 | if (release?.version_code!! >= versionCode) { 44 | text = "最新版本:${release?.version_name}" 45 | update = true 46 | } else { 47 | text = "已是最新版本,不需要更新" 48 | } 49 | } 50 | } catch (e: Exception) { 51 | Log.e(TAG, "Error occurred: ${e.message}", e) 52 | } 53 | updateUI(text, update) 54 | } 55 | } 56 | 57 | private fun updateUI(text: String, update: Boolean) { 58 | val dialog = ConfirmationFragment(this@UpdateManager, text, update) 59 | dialog.show((context as FragmentActivity).supportFragmentManager, TAG) 60 | } 61 | 62 | private fun startDownload(release: ReleaseResponse) { 63 | val apkName = "my-tv-0" 64 | val apkFileName = "$apkName-${release.version_name}.apk" 65 | val url = 66 | "${HttpClient.DOWNLOAD_HOST}${release.version_name}/$apkName-${release.version_name}.apk" 67 | var downloadDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) 68 | if (downloadDir == null) { 69 | downloadDir = File(context.filesDir, "downloads") 70 | } 71 | 72 | cleanupDownloadDirectory(downloadDir, apkName) 73 | val file = File(downloadDir, apkFileName) 74 | file.parentFile?.mkdirs() 75 | 76 | downloadJob = GlobalScope.launch(Dispatchers.IO) { 77 | downloadWithRetry(url, file) 78 | } 79 | } 80 | 81 | private fun cleanupDownloadDirectory(directory: File?, apkNamePrefix: String) { 82 | directory?.let { dir -> 83 | dir.listFiles()?.forEach { file -> 84 | if (file.name.startsWith(apkNamePrefix) && file.name.endsWith(".apk")) { 85 | val deleted = file.delete() 86 | if (deleted) { 87 | Log.i(TAG, "Deleted old APK file: ${file.name}") 88 | } else { 89 | Log.e(TAG, "Failed to delete old APK file: ${file.name}") 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | private suspend fun downloadWithRetry(url: String, file: File, maxRetries: Int = 3) { 97 | var retries = 0 98 | while (retries < maxRetries) { 99 | try { 100 | downloadFile(url, file) 101 | // If download is successful, break the loop 102 | break 103 | } catch (e: IOException) { 104 | Log.e(TAG, "Download failed: ${e.message}") 105 | retries++ 106 | if (retries >= maxRetries) { 107 | Log.e(TAG, "Max retries reached. Download failed.") 108 | withContext(Dispatchers.Main) { 109 | // Notify user about download failure 110 | updateUI("下载失败,请检查网络连接后重试", false) 111 | } 112 | } else { 113 | Log.i(TAG, "Retrying download (${retries}/${maxRetries})") 114 | delay(30000) // Wait for 30 seconds before retrying 115 | } 116 | } 117 | } 118 | } 119 | 120 | private suspend fun downloadFile(url: String, file: File) { 121 | val request = okhttp3.Request.Builder().url(url).build() 122 | val response = okHttpClient.newCall(request).execute() 123 | if (!response.isSuccessful) throw IOException("Unexpected code $response") 124 | 125 | val body = response.body() ?: throw IOException("Null response body") 126 | val contentLength = body.contentLength() 127 | var bytesRead = 0L 128 | 129 | body.byteStream().use { inputStream -> 130 | file.outputStream().use { outputStream -> 131 | val buffer = ByteArray(BUFFER_SIZE) 132 | var bytes: Int 133 | while (inputStream.read(buffer).also { bytes = it } != -1) { 134 | outputStream.write(buffer, 0, bytes) 135 | bytesRead += bytes 136 | val progress = 137 | if (contentLength > 0) (bytesRead * 100 / contentLength).toInt() else -1 138 | withContext(Dispatchers.Main) { 139 | updateDownloadProgress(progress) 140 | } 141 | } 142 | } 143 | } 144 | 145 | withContext(Dispatchers.Main) { 146 | installNewVersion(file) 147 | } 148 | } 149 | 150 | private fun updateDownloadProgress(progress: Int) { 151 | if (progress == -1) { 152 | // Log when progress can't be determined 153 | Log.i(TAG, "Download in progress, size unknown") 154 | } else if (progress % 10 == 0 && progress != lastLoggedProgress) { 155 | // Log every 10% and avoid duplicate logs 156 | Log.i(TAG, "Download progress: $progress%") 157 | lastLoggedProgress = progress 158 | "升级文件已经下载:${progress}%".showToast() 159 | } 160 | } 161 | 162 | private fun installNewVersion(apkFile: File) { 163 | if (apkFile.exists()) { 164 | val apkUri = Uri.fromFile(apkFile) // Use Uri.fromFile for Android 4.4 165 | Log.i(TAG, "apkUri $apkUri") 166 | val installIntent = Intent(Intent.ACTION_VIEW).apply { 167 | setDataAndType(apkUri, "application/vnd.android.package-archive") 168 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 169 | } 170 | context.startActivity(installIntent) 171 | } else { 172 | Log.e(TAG, "APK file does not exist!") 173 | } 174 | } 175 | 176 | companion object { 177 | private const val TAG = "UpdateManager" 178 | private const val BUFFER_SIZE = 8192 179 | } 180 | 181 | override fun onConfirm() { 182 | Log.i(TAG, "onConfirm $release") 183 | release?.let { startDownload(it) } 184 | } 185 | 186 | override fun onCancel() { 187 | } 188 | 189 | fun destroy() { 190 | downloadJob?.cancel() 191 | } 192 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0 2 | 3 | import android.content.res.Resources 4 | import android.os.Build 5 | import android.util.TypedValue 6 | import com.google.gson.Gson 7 | import com.lizongying.mytv0.ISP.CHINA_MOBILE 8 | import com.lizongying.mytv0.ISP.CHINA_TELECOM 9 | import com.lizongying.mytv0.ISP.CHINA_UNICOM 10 | import com.lizongying.mytv0.ISP.UNKNOWN 11 | import com.lizongying.mytv0.requests.HttpClient 12 | import com.lizongying.mytv0.requests.TimeResponse 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.launch 16 | import kotlinx.coroutines.withContext 17 | import java.text.SimpleDateFormat 18 | import java.util.Date 19 | import java.util.Locale 20 | 21 | enum class ISP { 22 | UNKNOWN, 23 | CHINA_MOBILE, 24 | CHINA_UNICOM, 25 | CHINA_TELECOM; 26 | 27 | fun fromName(name: String): ISP { 28 | val isp = when (name) { 29 | "ChinaMobile" -> CHINA_MOBILE 30 | "ChinaUnicom" -> CHINA_UNICOM 31 | "ChinaTelecom" -> CHINA_TELECOM 32 | else -> UNKNOWN 33 | } 34 | return isp 35 | } 36 | } 37 | 38 | data class IpInfo( 39 | val ip: String, 40 | val location: Location 41 | ) 42 | 43 | data class Location( 44 | val city_name: String, 45 | val country_name: String, 46 | val isp_domain: String, 47 | val latitude: String, 48 | val longitude: String, 49 | val owner_domain: String, 50 | val region_name: String, 51 | ) 52 | 53 | 54 | object Utils { 55 | private var between: Long = 0 56 | 57 | fun getDateFormat(format: String): String { 58 | return SimpleDateFormat( 59 | format, 60 | Locale.CHINA 61 | ).format(Date(System.currentTimeMillis() - between)) 62 | } 63 | 64 | fun getDateTimestamp(): Long { 65 | return (System.currentTimeMillis() - between) / 1000 66 | } 67 | 68 | suspend fun init() { 69 | try { 70 | val currentTimeMillis = getTimestampFromServer() 71 | if (currentTimeMillis > 0) { 72 | between = System.currentTimeMillis() - currentTimeMillis 73 | } 74 | } catch (e: Exception) { 75 | e.printStackTrace() 76 | } 77 | // 78 | // try { 79 | // val isp = getISP() 80 | // TVList.setISP(isp) 81 | // } catch (e: Exception) { 82 | // e.printStackTrace() 83 | // } 84 | } 85 | 86 | init { 87 | CoroutineScope(Dispatchers.IO).launch { 88 | init() 89 | } 90 | } 91 | 92 | /** 93 | * 从服务器获取时间戳 94 | * @return Long 时间戳 95 | */ 96 | private suspend fun getTimestampFromServer(): Long { 97 | return withContext(Dispatchers.IO) { 98 | val client = HttpClient.okHttpClient 99 | val request = okhttp3.Request.Builder() 100 | .url("https://api.m.taobao.com/rest/api3.do?api=mtop.common.getTimestamp") 101 | .build() 102 | try { 103 | client.newCall(request).execute().use { response -> 104 | if (!response.isSuccessful) return@withContext 0 105 | val string = response.body()?.string() 106 | Gson().fromJson(string, TimeResponse::class.java).data.t.toLong() 107 | } 108 | } catch (e: Exception) { 109 | e.printStackTrace() 110 | 0 111 | } 112 | } 113 | } 114 | 115 | suspend fun getISP(): ISP { 116 | return withContext(Dispatchers.IO) { 117 | val client = HttpClient.okHttpClient 118 | val request = okhttp3.Request.Builder() 119 | .url("https://api.myip.la/json") 120 | .build() 121 | try { 122 | client.newCall(request).execute().use { response -> 123 | if (!response.isSuccessful) return@withContext UNKNOWN 124 | val string = response.body()?.string() 125 | val isp = Gson().fromJson(string, IpInfo::class.java).location.isp_domain 126 | when (isp) { 127 | "ChinaMobile" -> CHINA_MOBILE 128 | "ChinaUnicom" -> CHINA_UNICOM 129 | "ChinaTelecom" -> CHINA_TELECOM 130 | else -> UNKNOWN 131 | } 132 | } 133 | } catch (e: Exception) { 134 | e.printStackTrace() 135 | UNKNOWN 136 | } 137 | } 138 | } 139 | 140 | fun dpToPx(dp: Float): Int { 141 | return TypedValue.applyDimension( 142 | TypedValue.COMPLEX_UNIT_DIP, dp, Resources.getSystem().displayMetrics 143 | ).toInt() 144 | } 145 | 146 | fun dpToPx(dp: Int): Int { 147 | return TypedValue.applyDimension( 148 | TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), Resources.getSystem().displayMetrics 149 | ).toInt() 150 | } 151 | 152 | fun isTmallDevice() = Build.MANUFACTURER.equals("Tmall", ignoreCase = true) 153 | 154 | fun formatUrl(url: String): String { 155 | // Check if the URL already starts with "http://" or "https://" 156 | if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { 157 | return url 158 | } 159 | 160 | // Check if the URL starts with "//" 161 | if (url.startsWith("//")) { 162 | return "http://$url" 163 | } 164 | 165 | // Otherwise, add "http://" to the beginning of the URL 166 | return "http://${url}" 167 | } 168 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/models/EPG.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.models 2 | 3 | 4 | data class EPG( 5 | val title: String, 6 | val beginTime: Int, 7 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/models/EPGXmlParser.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.models 2 | 3 | import android.util.Xml 4 | import org.xmlpull.v1.XmlPullParser 5 | import java.io.InputStream 6 | import java.text.SimpleDateFormat 7 | import java.util.Locale 8 | 9 | 10 | class EPGXmlParser { 11 | 12 | private val ns: String? = null 13 | 14 | private val epg = mutableMapOf>() 15 | 16 | private fun formatFTime(s: String): Int { 17 | val dateFormat = SimpleDateFormat("yyyyMMddHHmmss Z", Locale.getDefault()) 18 | val date = dateFormat.parse(s) 19 | if (date != null) { 20 | return (date.time / 1000).toInt() 21 | } 22 | return 0 23 | } 24 | 25 | fun parse(inputStream: InputStream): MutableMap> { 26 | inputStream.use { input -> 27 | val parser: XmlPullParser = Xml.newPullParser() 28 | parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) 29 | parser.setInput(input, null) 30 | parser.nextTag() 31 | var channel = "" 32 | while (parser.eventType != XmlPullParser.END_DOCUMENT) { 33 | if (parser.eventType != XmlPullParser.START_TAG) { 34 | parser.next() 35 | continue 36 | } 37 | if (parser.name == "channel") { 38 | parser.nextTag() 39 | channel = parser.nextText() 40 | epg[channel] = mutableListOf() 41 | } else if (parser.name == "programme") { 42 | val start = parser.getAttributeValue(ns, "start") 43 | parser.nextTag() 44 | val title = parser.nextText() 45 | epg[channel]?.add(EPG(title, formatFTime(start))) 46 | } 47 | parser.next() 48 | } 49 | } 50 | return epg 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/models/Program.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.models 2 | 3 | import java.io.Serializable 4 | 5 | data class Program( 6 | var id: Int = 0, 7 | var title: String = "", 8 | var description: String? = null, 9 | var logo: String = "", 10 | var image: String? = null, 11 | ) : Serializable { 12 | 13 | override fun toString(): String { 14 | return "Program{" + 15 | "id=" + id + 16 | ", title='" + title + '\'' + 17 | ", description='" + description + '\'' + 18 | ", logo='" + logo + '\'' + 19 | ", image='" + image + '\'' + 20 | '}' 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/models/SourceType.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.models 2 | 3 | enum class SourceType { 4 | UNKNOWN, 5 | HLS, 6 | DASH, 7 | RTSP, 8 | PROGRESSIVE, 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/models/TV.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.models 2 | 3 | import java.io.Serializable 4 | 5 | data class TV( 6 | var id: Int = -1, 7 | var name: String = "", 8 | var title: String = "", 9 | var description: String? = null, 10 | var logo: String = "", 11 | var image: String? = null, 12 | var uris: List, 13 | var headers: Map? = null, 14 | var group: String = "", 15 | var sourceType: SourceType = SourceType.UNKNOWN, 16 | var child: List, 17 | ) : Serializable { 18 | 19 | override fun toString(): String { 20 | return "TV{" + 21 | "id=" + id + 22 | ", name='" + name + '\'' + 23 | ", title='" + title + '\'' + 24 | ", description='" + description + '\'' + 25 | ", logo='" + logo + '\'' + 26 | ", image='" + image + '\'' + 27 | ", uris='" + uris + '\'' + 28 | ", headers='" + headers + '\'' + 29 | ", group='" + group + '\'' + 30 | ", sourceType='" + sourceType + '\'' + 31 | '}' 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/models/TVGroupModel.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.models 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.ViewModel 7 | import com.lizongying.mytv0.ISP 8 | import com.lizongying.mytv0.SP 9 | 10 | class TVGroupModel : ViewModel() { 11 | var isInLikeMode = false 12 | private val _tvGroup = MutableLiveData>() 13 | val tvGroup: LiveData> 14 | get() = _tvGroup 15 | val tvGroupValue: List 16 | get() = _tvGroup.value ?: listOf() 17 | 18 | // Filtered 19 | private val _position = MutableLiveData() 20 | val position: LiveData 21 | get() = _position 22 | val positionValue: Int 23 | get() = _position.value ?: 0 24 | 25 | private val _positionPlaying = MutableLiveData() 26 | val positionPlaying: LiveData 27 | get() = _positionPlaying 28 | val positionPlayingValue: Int 29 | get() = _positionPlaying.value ?: 0 30 | 31 | private val _change = MutableLiveData() 32 | val change: LiveData 33 | get() = _change 34 | 35 | private var isp = ISP.UNKNOWN 36 | fun setISP(isp: ISP) { 37 | this.isp = isp 38 | } 39 | 40 | fun setPosition(position: Int) { 41 | Log.i(TAG, "group setPosition $position") 42 | _position.value = position 43 | } 44 | 45 | fun setPositionPlaying(position: Int) { 46 | _positionPlaying.value = position 47 | SP.positionGroup = position 48 | } 49 | 50 | fun setPlaying() { 51 | _positionPlaying.value = positionValue 52 | SP.positionGroup = positionValue 53 | } 54 | 55 | fun setPrevPosition() { 56 | _position.value = (positionValue - 1) % size() 57 | } 58 | 59 | fun setNextPosition() { 60 | _position.value = (positionValue + 1) % size() 61 | } 62 | 63 | fun setChange() { 64 | _change.value = true 65 | } 66 | 67 | fun setTVListModelList(tvGroup: List) { 68 | _tvGroup.value = tvGroup 69 | } 70 | 71 | fun addTVListModel(listTVModel: TVListModel) { 72 | if (_tvGroup.value == null) { 73 | _tvGroup.value = mutableListOf(listTVModel) 74 | return 75 | } 76 | 77 | val newList = _tvGroup.value!!.toMutableList() 78 | newList.add(listTVModel) 79 | _tvGroup.value = newList 80 | } 81 | 82 | fun initTVGroup() { 83 | _tvGroup.value = mutableListOf( 84 | (_tvGroup.value as List)[0], 85 | (_tvGroup.value as List)[1] 86 | ) 87 | (_tvGroup.value as List)[1].initTVList() 88 | } 89 | //API19-: clear() in ViewModel must rename! 90 | fun clear2() { 91 | if (SP.showAllChannels) { 92 | _tvGroup.value = 93 | mutableListOf(getFavoritesList()!!, getAllList()!!) 94 | setPosition(0) 95 | getAllList()?.clear2() 96 | } else { 97 | _tvGroup.value = mutableListOf(getFavoritesList()!!) 98 | setPosition(0) 99 | } 100 | } 101 | 102 | fun getTVListModel(): TVListModel? { 103 | return getTVListModel(positionValue) 104 | } 105 | 106 | fun getTVListModel(idx: Int): TVListModel? { 107 | if (idx >= size()) { 108 | return null 109 | } 110 | if (SP.showAllChannels) { 111 | return _tvGroup.value?.get(idx) 112 | } 113 | return _tvGroup.value?.filter { it.getName() != "全部頻道" }?.get(idx) 114 | } 115 | 116 | fun getTVListModelNotFilter(idx: Int): TVListModel? { 117 | if (idx >= tvGroupValue.size) { 118 | return null 119 | } 120 | 121 | return _tvGroup.value?.get(idx) 122 | } 123 | 124 | // get & set 125 | fun getPosition(position: Int): TVModel? { 126 | 127 | // No item 128 | if (tvGroupValue[1].size() == 0) { 129 | return null 130 | } 131 | 132 | var count = 0 133 | for ((index, i) in tvGroupValue.withIndex()) { 134 | val countBefore = count 135 | count += i.size() 136 | if (count > position) { 137 | setPosition(index) 138 | val listPosition = position - countBefore 139 | i.setPosition(listPosition) 140 | return i.getTVModel(listPosition) 141 | } 142 | } 143 | 144 | return null 145 | } 146 | 147 | fun getCurrent(): TVModel? { 148 | 149 | // No item 150 | if (tvGroupValue.size < 2 || tvGroupValue[1].size() == 0) { 151 | return null 152 | } 153 | 154 | return getCurrentList()?.getCurrent() 155 | } 156 | 157 | fun getCurrentList(): TVListModel? { 158 | return getTVListModelNotFilter(positionValue) 159 | } 160 | 161 | fun getFavoritesList(): TVListModel? { 162 | return getTVListModelNotFilter(0) 163 | } 164 | 165 | fun getAllList(): TVListModel? { 166 | return getTVListModelNotFilter(1) 167 | } 168 | 169 | // get & set 170 | // keep: In the current list loop 171 | fun getPrev(keep: Boolean = false): TVModel? { 172 | 173 | Log.i(TAG, "keep $keep") 174 | 175 | // No item 176 | if (tvGroupValue.size < 2 || tvGroupValue[1].size() == 0) { 177 | return null 178 | } 179 | 180 | var tvListModel = getCurrentList()!! 181 | if (keep) { 182 | Log.i(TAG, "group position $positionValue") 183 | return tvListModel.getPrev() 184 | } 185 | 186 | // Prev tvListModel 187 | if (tvListModel.positionValue == 0) { 188 | var p = (tvGroupValue.size + positionValue - 1) % tvGroupValue.size 189 | setPosition(p) 190 | if (p == 1) { 191 | p = (tvGroupValue.size + positionValue - 1) % tvGroupValue.size 192 | setPosition(p) 193 | } 194 | if (p == 0) { 195 | p = (tvGroupValue.size + positionValue - 1) % tvGroupValue.size 196 | setPosition(p) 197 | } 198 | 199 | Log.i(TAG, "group position $p/${tvGroupValue.size}") 200 | tvListModel = getTVListModelNotFilter(p)!! 201 | return tvListModel.getTVModel(tvListModel.size() - 1) 202 | } 203 | 204 | return tvListModel.getPrev() 205 | } 206 | 207 | // get & set 208 | fun getNext(keep: Boolean = false): TVModel? { 209 | Log.i(TAG, "keep $keep") 210 | 211 | // No item 212 | if (tvGroupValue.size < 2 || tvGroupValue[1].size() == 0) { 213 | return null 214 | } 215 | 216 | var tvListModel = getCurrentList()!! 217 | if (keep) { 218 | return tvListModel.getNext() 219 | } 220 | 221 | // Next tvListModel 222 | if (tvListModel.positionValue == tvListModel.size() - 1) { 223 | var p = (positionValue + 1) % tvGroupValue.size 224 | setPosition(p) 225 | if (p == 0) { 226 | p = (tvGroupValue.size + positionValue + 1) % tvGroupValue.size 227 | setPosition(p) 228 | } 229 | if (p == 1) { 230 | p = (tvGroupValue.size + positionValue + 1) % tvGroupValue.size 231 | setPosition(p) 232 | } 233 | 234 | Log.i(TAG, "group position $p/${tvGroupValue.size}") 235 | tvListModel = getTVListModelNotFilter(p)!! 236 | return tvListModel.getTVModel(0) 237 | } 238 | 239 | return tvListModel.getNext() 240 | } 241 | 242 | init { 243 | _position.value = SP.positionGroup 244 | Log.i(TAG, "SP.positionGroup ${SP.positionGroup}") 245 | isInLikeMode = SP.defaultLike && _position.value == 0 246 | } 247 | 248 | fun size(): Int { 249 | if (_tvGroup.value == null) { 250 | return 0 251 | } 252 | if (SP.showAllChannels) { 253 | return _tvGroup.value!!.size 254 | } 255 | return _tvGroup.value!!.filter { it.getName() != "全部頻道" }.size 256 | } 257 | 258 | companion object { 259 | const val TAG = "TVGroupModel" 260 | } 261 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/models/TVListModel.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.models 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.ViewModel 7 | import com.lizongying.mytv0.SP 8 | 9 | class TVListModel(private val name: String, private val groupIndex: Int) : ViewModel() { 10 | fun getName(): String { 11 | return name 12 | } 13 | 14 | // position in tvGroup. No filters 15 | fun getGroupIndex(): Int { 16 | return groupIndex 17 | } 18 | 19 | private val _tvList = MutableLiveData>() 20 | val tvList: LiveData> 21 | get() = _tvList 22 | private val tvListValue: List 23 | get() = _tvList.value ?: listOf() 24 | 25 | private val _position = MutableLiveData() 26 | val position: LiveData 27 | get() = _position 28 | val positionValue: Int 29 | get() = _position.value ?: 0 30 | 31 | private val _positionPlaying = MutableLiveData() 32 | val positionPlaying: LiveData 33 | get() = _positionPlaying 34 | val positionPlayingValue: Int 35 | get() = _positionPlaying.value ?: 0 36 | 37 | fun setPosition(position: Int) { 38 | Log.i(TAG, "list setPosition $position") 39 | _position.value = position 40 | } 41 | 42 | fun setPositionPlaying(position: Int) { 43 | _positionPlaying.value = position 44 | SP.position = position 45 | } 46 | 47 | fun setPlaying() { 48 | _positionPlaying.value = positionValue 49 | SP.position = positionValue 50 | } 51 | 52 | private val _change = MutableLiveData() 53 | val change: LiveData 54 | get() = _change 55 | 56 | fun setChange() { 57 | _change.value = true 58 | } 59 | 60 | fun setTVListModel(tvList: List) { 61 | _tvList.value = tvList 62 | } 63 | 64 | fun addTVModel(tvModel: TVModel) { 65 | if (_tvList.value == null) { 66 | _tvList.value = mutableListOf(tvModel) 67 | return 68 | } 69 | 70 | val newList = _tvList.value!!.toMutableList() 71 | newList.add(tvModel) 72 | _tvList.value = newList 73 | } 74 | 75 | fun removeTVModel(id: Int) { 76 | if (_tvList.value == null) { 77 | return 78 | } 79 | val newList = _tvList.value!!.toMutableList() 80 | val iterator = newList.iterator() 81 | while (iterator.hasNext()) { 82 | if (iterator.next().tv.id == id) { 83 | iterator.remove() 84 | } 85 | } 86 | _tvList.value = newList 87 | } 88 | 89 | fun replaceTVModel(tvModel: TVModel) { 90 | if (_tvList.value == null) { 91 | _tvList.value = mutableListOf(tvModel) 92 | return 93 | } 94 | 95 | val newList = _tvList.value!!.toMutableList() 96 | var exists = false 97 | val iterator = newList.iterator() 98 | while (iterator.hasNext()) { 99 | if (iterator.next().tv.id == tvModel.tv.id) { 100 | exists = true 101 | } 102 | } 103 | if (!exists) { 104 | newList.add(tvModel) 105 | _tvList.value = newList 106 | } 107 | } 108 | 109 | fun initTVList() { 110 | _tvList.value = mutableListOf() 111 | } 112 | //API19-: clear() in ViewModel must rename! 113 | fun clear2() { 114 | initTVList() 115 | setPosition(0) 116 | } 117 | 118 | fun getTVModel(): TVModel? { 119 | return getTVModel(positionValue) 120 | } 121 | 122 | fun getTVModel(idx: Int): TVModel? { 123 | if (idx >= size()) { 124 | return null 125 | } 126 | setPosition(idx) 127 | return tvListValue[idx] 128 | } 129 | 130 | fun getCurrent(): TVModel? { 131 | if (positionValue < 0 || positionValue >= size()) { 132 | return getTVModel(0) 133 | } 134 | return getTVModel(positionValue) 135 | } 136 | 137 | fun getPrev(): TVModel? { 138 | if (size() == 0) { 139 | return null 140 | } 141 | 142 | val p = (size() + positionValue - 1) % size() 143 | setPosition(p) 144 | return tvListValue[p] 145 | } 146 | 147 | fun getNext(): TVModel? { 148 | if (size() == 0) { 149 | return null 150 | } 151 | 152 | val p = (positionValue + 1) % size() 153 | setPosition(p) 154 | return tvListValue[p] 155 | } 156 | 157 | init { 158 | _position.value = SP.position 159 | Log.i(TAG, "SP.position ${SP.position}") 160 | } 161 | 162 | fun size(): Int { 163 | if (_tvList.value == null) { 164 | return 0 165 | } 166 | 167 | return tvListValue.size 168 | } 169 | 170 | companion object { 171 | const val TAG = "TVListModel" 172 | } 173 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/models/TVModel.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.models 2 | 3 | import android.net.Uri 4 | import androidx.annotation.OptIn 5 | import androidx.lifecycle.LiveData 6 | import androidx.lifecycle.MutableLiveData 7 | import androidx.lifecycle.ViewModel 8 | import androidx.media3.common.MediaItem 9 | import androidx.media3.common.util.Log 10 | import androidx.media3.common.util.UnstableApi 11 | import androidx.media3.datasource.DefaultHttpDataSource 12 | import androidx.media3.exoplayer.dash.DashMediaSource 13 | import androidx.media3.exoplayer.hls.HlsMediaSource 14 | import androidx.media3.exoplayer.rtsp.RtspMediaSource 15 | import androidx.media3.exoplayer.source.MediaSource 16 | import androidx.media3.exoplayer.source.ProgressiveMediaSource 17 | import com.lizongying.mytv0.SP 18 | import kotlin.math.max 19 | import kotlin.math.min 20 | 21 | class TVModel(var tv: TV) : ViewModel() { 22 | private val _position = MutableLiveData() 23 | val position: LiveData 24 | get() = _position 25 | 26 | var retryTimes = 0 27 | var retryMaxTimes = 3 28 | var programUpdateTime = 0L 29 | 30 | private var _groupIndex = 0 31 | val groupIndex: Int 32 | get() = if (SP.showAllChannels || _groupIndex == 0) _groupIndex else _groupIndex - 1 33 | 34 | fun setGroupIndex(index: Int) { 35 | _groupIndex = index 36 | } 37 | 38 | fun getGroupIndexInAll(): Int { 39 | return _groupIndex 40 | } 41 | 42 | var listIndex = 0 43 | 44 | private var sources: MutableList = 45 | mutableListOf( 46 | SourceType.UNKNOWN, 47 | ) 48 | private var sourceIndex = -1 49 | 50 | private val _errInfo = MutableLiveData() 51 | val errInfo: LiveData 52 | get() = _errInfo 53 | 54 | fun setErrInfo(info: String) { 55 | _errInfo.value = info 56 | } 57 | 58 | private var _epg = MutableLiveData>() 59 | val epg: LiveData> 60 | get() = _epg 61 | 62 | fun setEpg(epg: MutableList) { 63 | _epg.value = epg 64 | } 65 | 66 | private var _program = MutableLiveData>() 67 | val program: LiveData> 68 | get() = _program 69 | 70 | private fun getVideoUrl(): String? { 71 | if (_videoIndex.value == null || tv.uris.isEmpty()) { 72 | return null 73 | } 74 | 75 | if (videoIndex.value!! >= tv.uris.size) { 76 | return null 77 | } 78 | 79 | val index = min(max(_videoIndex.value!!, 0), tv.uris.size - 1) 80 | return tv.uris[index] 81 | } 82 | 83 | private val _like = MutableLiveData() 84 | val like: LiveData 85 | get() = _like 86 | 87 | fun setLike(liked: Boolean) { 88 | _like.value = liked 89 | } 90 | 91 | private val _ready = MutableLiveData() 92 | val ready: LiveData 93 | get() = _ready 94 | 95 | fun setReady() { 96 | // _videoIndex.value = (_videoIndex.value!! + 1) % tv.uris.size 97 | // if (tv.uris.size < 2) { 98 | // _videoIndex.value = 0 99 | // } else { 100 | // _videoIndex.value = Random.nextInt(0, tv.uris.size - 1) 101 | // } 102 | _ready.value = true 103 | } 104 | 105 | private val _videoIndex = MutableLiveData() 106 | private val videoIndex: LiveData 107 | get() = _videoIndex 108 | 109 | private var userAgent = "" 110 | 111 | // TODO Maybe _mediaItem has not been initialized when play 112 | private lateinit var _mediaItem: MediaItem 113 | 114 | fun getMediaItem(): MediaItem { 115 | if (::_mediaItem.isInitialized) { 116 | return _mediaItem 117 | } else { 118 | // TODO Maybe url is null 119 | _mediaItem = MediaItem.fromUri(getVideoUrl()!!) 120 | return _mediaItem 121 | } 122 | } 123 | 124 | private lateinit var httpDataSource: DefaultHttpDataSource.Factory 125 | 126 | init { 127 | _position.value = 0 128 | _videoIndex.value = max(0, tv.uris.size - 1) 129 | _like.value = SP.getLike(tv.id) 130 | _program.value = mutableListOf() 131 | 132 | buildSource() 133 | } 134 | 135 | fun update(t: TV) { 136 | tv = t 137 | } 138 | 139 | @OptIn(UnstableApi::class) 140 | fun buildSource() { 141 | val url = getVideoUrl() ?: return 142 | val uri = Uri.parse(url) ?: return 143 | val path = uri.path ?: return 144 | val scheme = uri.scheme ?: return 145 | 146 | httpDataSource = DefaultHttpDataSource.Factory() 147 | httpDataSource.setKeepPostFor302Redirects(true) 148 | httpDataSource.setAllowCrossProtocolRedirects(true) 149 | httpDataSource.setConnectTimeoutMs(5000).setReadTimeoutMs(5000) 150 | tv.headers?.let { 151 | httpDataSource.setDefaultRequestProperties(it) 152 | it.forEach { (key, value) -> 153 | if (key.equals("user-agent", ignoreCase = true)) { 154 | userAgent = value 155 | return@forEach 156 | } 157 | } 158 | } 159 | 160 | _mediaItem = MediaItem.fromUri(uri.toString()) 161 | 162 | if (path.lowercase().endsWith(".m3u8")) { 163 | addSource(SourceType.HLS) 164 | } else if (path.lowercase().endsWith(".mpd")) { 165 | addSource(SourceType.DASH) 166 | } else if (scheme.lowercase() == "rtsp" || scheme.lowercase() == "rtp") { 167 | addSource(SourceType.RTSP) 168 | } else if (path.lowercase().substringAfterLast(".", "") 169 | .let { it.isNotEmpty() && videoExtensions.contains(it) } 170 | ) { 171 | addSource(SourceType.PROGRESSIVE) 172 | } else { 173 | Log.w(TAG, "URL SourceType UNKNOWN: ${uri.toString()}") 174 | addSource(SourceType.UNKNOWN) 175 | } 176 | 177 | nextSource() 178 | } 179 | 180 | private fun addSource(sourceType: SourceType) { 181 | sources[0] = sourceType 182 | 183 | for (i in listOf( 184 | SourceType.PROGRESSIVE, 185 | SourceType.HLS, 186 | SourceType.RTSP, 187 | SourceType.DASH, 188 | SourceType.UNKNOWN 189 | )) { 190 | if (i != sourceType) { 191 | sources.add(i) 192 | } 193 | } 194 | } 195 | 196 | fun getSourceType(): SourceType { 197 | return tv.sourceType 198 | } 199 | 200 | fun getSourceTypeCurrent(): SourceType { 201 | return sources[sourceIndex] 202 | } 203 | 204 | fun nextSource() { 205 | sourceIndex = (sourceIndex + 1) % sources.size 206 | } 207 | 208 | @OptIn(UnstableApi::class) 209 | fun getSource(): MediaSource? { 210 | if (sources.isEmpty()) { 211 | return null 212 | } 213 | if (!::_mediaItem.isInitialized) { 214 | return null 215 | } 216 | sourceIndex = max(0, sourceIndex) 217 | sourceIndex = min(sourceIndex, sources.size - 1) 218 | 219 | return when (sources[sourceIndex]) { 220 | SourceType.HLS -> HlsMediaSource.Factory(httpDataSource).createMediaSource(_mediaItem) 221 | SourceType.RTSP -> if (userAgent.isEmpty()) { 222 | RtspMediaSource.Factory().createMediaSource(_mediaItem) 223 | } else { 224 | RtspMediaSource.Factory().setUserAgent(userAgent).createMediaSource(_mediaItem) 225 | } 226 | 227 | SourceType.DASH -> DashMediaSource.Factory(httpDataSource).createMediaSource(_mediaItem) 228 | SourceType.PROGRESSIVE -> ProgressiveMediaSource.Factory(httpDataSource) 229 | .createMediaSource(_mediaItem) 230 | 231 | else -> null 232 | } 233 | } 234 | 235 | fun confirmSourceType() { 236 | // TODO save default sourceType 237 | tv.sourceType = sources[sourceIndex] 238 | } 239 | 240 | companion object { 241 | private const val TAG = "TVModel" 242 | val videoExtensions = setOf( 243 | ".flv", ".mp4", ".avi", ".mkv", ".mov", ".mpeg", "wmv", "webm" 244 | ) 245 | } 246 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/requests/ConfigService.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.requests 2 | 3 | import retrofit2.Call 4 | import retrofit2.http.Url 5 | 6 | interface ConfigService { 7 | fun getConfig( 8 | @Url url: String 9 | ): Call 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/requests/DnsCache.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.requests 2 | 3 | import android.text.TextUtils 4 | import okhttp3.Dns 5 | import java.net.Inet4Address 6 | import java.net.InetAddress 7 | import java.util.concurrent.ConcurrentHashMap 8 | 9 | 10 | class DnsCache : Dns { 11 | private val dnsCache: MutableMap> = ConcurrentHashMap() 12 | 13 | override fun lookup(hostname: String): List { 14 | if (TextUtils.isEmpty(hostname)) { 15 | return Dns.SYSTEM.lookup(hostname); 16 | } 17 | 18 | dnsCache[hostname]?.let { 19 | return it 20 | } 21 | 22 | val addressesNew: MutableList = ArrayList() 23 | 24 | val addresses = InetAddress.getAllByName(hostname).toList() 25 | for (address in addresses) { 26 | if (address is Inet4Address) { 27 | addressesNew.add(0, address); 28 | } else { 29 | addressesNew.add(address); 30 | } 31 | } 32 | 33 | if (addressesNew.isNotEmpty()) { 34 | dnsCache[hostname] = addressesNew 35 | } 36 | 37 | return addressesNew 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/requests/HttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.requests 2 | 3 | import android.util.Log 4 | import com.lizongying.mytv0.MyTVApplication 5 | import okhttp3.ConnectionSpec 6 | import okhttp3.Interceptor 7 | import okhttp3.OkHttpClient 8 | import okhttp3.logging.HttpLoggingInterceptor 9 | import org.conscrypt.Conscrypt 10 | import retrofit2.Retrofit 11 | import retrofit2.converter.gson.GsonConverterFactory 12 | import java.security.Security 13 | import java.util.Collections 14 | import java.util.concurrent.TimeUnit 15 | import javax.net.ssl.SSLContext 16 | 17 | object HttpClient { 18 | const val TAG = "HttpClient" 19 | private const val HOST = "https://mirror.ghproxy.com/https://raw.githubusercontent.com/vrichv/my-tv-0/" 20 | const val DOWNLOAD_HOST = 21 | "https://mirror.ghproxy.com/https://github.com/vrichv/my-tv-0/releases/download/" 22 | 23 | val okHttpClient: OkHttpClient by lazy { 24 | getSafeOkHttpClient() 25 | } 26 | 27 | val releaseService: ReleaseService by lazy { 28 | Retrofit.Builder() 29 | .baseUrl(HOST) 30 | .client(okHttpClient) 31 | .addConverterFactory(GsonConverterFactory.create()) 32 | .build().create(ReleaseService::class.java) 33 | } 34 | 35 | val configService: ConfigService by lazy { 36 | Retrofit.Builder() 37 | .client(okHttpClient) 38 | .build().create(ConfigService::class.java) 39 | } 40 | 41 | private fun getSafeOkHttpClient(): OkHttpClient { 42 | // Init Conscrypt 43 | val conscrypt = Conscrypt.newProvider() 44 | // Add as provider 45 | Security.insertProviderAt(conscrypt, 1) 46 | // OkHttp 3.12.x 47 | // ConnectionSpec.COMPATIBLE_TLS = TLS1.0 48 | // ConnectionSpec.MODERN_TLS = TLS1.0 + TLS1.1 + TLS1.2 + TLS 1.3 49 | // ConnectionSpec.RESTRICTED_TLS = TLS 1.2 + TLS 1.3 50 | val okHttpBuilder = OkHttpClient.Builder() 51 | .connectionSpecs(Collections.singletonList(ConnectionSpec.RESTRICTED_TLS)) 52 | 53 | val userAgentInterceptor = Interceptor { chain -> 54 | val originalRequest = chain.request() 55 | val requestWithUserAgent = originalRequest.newBuilder() 56 | .header( 57 | "User-Agent", 58 | "Mozilla/5.0 (Linux; Android 4.4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.114 Mobile Safari/537.36" 59 | ) 60 | .build() 61 | chain.proceed(requestWithUserAgent) 62 | } 63 | 64 | val loggingInterceptor = HttpLoggingInterceptor().apply { 65 | level = HttpLoggingInterceptor.Level.BASIC 66 | } 67 | 68 | try { 69 | val tm = InternalX509TrustManager(MyTVApplication.getInstance().applicationContext) 70 | val sslContext = SSLContext.getInstance("TLS", conscrypt) 71 | sslContext.init(null, arrayOf(tm), null) 72 | okHttpBuilder.sslSocketFactory(InternalSSLSocketFactory(sslContext.socketFactory), tm) 73 | } catch (e: Exception) { 74 | Log.e(TAG, "Error setting up OkHttpClient", e) 75 | } 76 | 77 | return okHttpBuilder.dns(DnsCache()).retryOnConnectionFailure(true) 78 | .connectTimeout(30, TimeUnit.SECONDS) 79 | .readTimeout(60, TimeUnit.SECONDS) 80 | .writeTimeout(30, TimeUnit.SECONDS).addInterceptor(userAgentInterceptor) 81 | .addInterceptor(loggingInterceptor).build() 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/requests/InternalSSLSocketFactory.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.requests 2 | 3 | import java.io.IOException 4 | import java.net.InetAddress 5 | import java.net.Socket 6 | import javax.net.ssl.SSLSocket 7 | import javax.net.ssl.SSLSocketFactory 8 | 9 | class InternalSSLSocketFactory(private val mSSLSocketFactory: SSLSocketFactory) : SSLSocketFactory() { 10 | 11 | override fun getDefaultCipherSuites(): Array { 12 | return mSSLSocketFactory.defaultCipherSuites 13 | } 14 | 15 | override fun getSupportedCipherSuites(): Array { 16 | return mSSLSocketFactory.supportedCipherSuites 17 | } 18 | 19 | @Throws(IOException::class) 20 | override fun createSocket(): Socket { 21 | return enableTLSOnSocket(mSSLSocketFactory.createSocket()) 22 | } 23 | 24 | @Throws(IOException::class) 25 | override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket { 26 | return enableTLSOnSocket(mSSLSocketFactory.createSocket(s, host, port, autoClose)) 27 | } 28 | 29 | @Throws(IOException::class) 30 | override fun createSocket(host: String, port: Int): Socket { 31 | return enableTLSOnSocket(mSSLSocketFactory.createSocket(host, port)) 32 | } 33 | 34 | @Throws(IOException::class) 35 | override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket { 36 | return enableTLSOnSocket(mSSLSocketFactory.createSocket(host, port, localHost, localPort)) 37 | } 38 | 39 | @Throws(IOException::class) 40 | override fun createSocket(host: InetAddress, port: Int): Socket { 41 | return enableTLSOnSocket(mSSLSocketFactory.createSocket(host, port)) 42 | } 43 | 44 | @Throws(IOException::class) 45 | override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket { 46 | return enableTLSOnSocket(mSSLSocketFactory.createSocket(address, port, localAddress, localPort)) 47 | } 48 | 49 | private fun enableTLSOnSocket(socket: Socket): Socket { 50 | if (socket is SSLSocket) { 51 | socket.enabledProtocols = arrayOf("TLSv1.2", "TLSv1.3") 52 | } 53 | return socket 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/requests/InternalX509TrustManager.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.requests 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import java.io.ByteArrayInputStream 6 | import java.security.KeyStore 7 | import java.security.cert.CertificateFactory 8 | import java.security.cert.X509Certificate 9 | import javax.net.ssl.TrustManager 10 | import javax.net.ssl.TrustManagerFactory 11 | import javax.net.ssl.X509TrustManager 12 | 13 | //this code can use https://badssl.com to test 14 | //it can deal with self-signed/expired/wrong host/untrusted root certificate 15 | //it can't deal with certificate pinning and revoked certificate 16 | //latest pem file can be downloaded from https://curl.se/ca/cacert.pem 17 | class InternalX509TrustManager(private val context: Context) : X509TrustManager { 18 | private val TAG = "CustomX509TrustManager" 19 | private val trustManagers: Array 20 | 21 | init { 22 | val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) 23 | keyStore.load(null, null) 24 | 25 | val certificateFactory = CertificateFactory.getInstance("X.509") 26 | val certificates = loadCertificatesFromAssets() 27 | 28 | certificates.forEachIndexed { index, certString -> 29 | val cert = 30 | certificateFactory.generateCertificate(ByteArrayInputStream(certString.toByteArray())) as X509Certificate 31 | keyStore.setCertificateEntry("ca$index", cert) 32 | } 33 | 34 | val trustManagerFactory = 35 | TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) 36 | trustManagerFactory.init(keyStore) 37 | trustManagers = trustManagerFactory.trustManagers 38 | } 39 | 40 | private fun loadCertificatesFromAssets(): List { 41 | val certificates = mutableListOf() 42 | try { 43 | val inputStream = context.assets.open("cacert.pem") 44 | val content = inputStream.bufferedReader().use { it.readText() } 45 | 46 | val certRegex = 47 | "-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----".toRegex(RegexOption.DOT_MATCHES_ALL) 48 | certRegex.findAll(content).forEach { matchResult -> 49 | certificates.add(matchResult.value) 50 | } 51 | } catch (e: Exception) { 52 | Log.e(TAG, "Error loading certificates from assets", e) 53 | } 54 | return certificates 55 | } 56 | 57 | override fun checkClientTrusted(chain: Array?, authType: String?) { 58 | (trustManagers[0] as X509TrustManager).checkClientTrusted(chain, authType) 59 | } 60 | 61 | override fun checkServerTrusted(chain: Array?, authType: String?) { 62 | (trustManagers[0] as X509TrustManager).checkServerTrusted(chain, authType) 63 | } 64 | 65 | override fun getAcceptedIssuers(): Array { 66 | return (trustManagers[0] as X509TrustManager).acceptedIssuers 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/requests/ReleaseRequest.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.requests 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import retrofit2.Call 6 | import retrofit2.Callback 7 | import retrofit2.Response 8 | import kotlin.coroutines.resume 9 | import kotlin.coroutines.suspendCoroutine 10 | 11 | class ReleaseRequest { 12 | 13 | suspend fun getRelease(): ReleaseResponse? { 14 | return withContext(Dispatchers.IO) { 15 | fetchRelease() 16 | } 17 | } 18 | 19 | private suspend fun fetchRelease(): ReleaseResponse? { 20 | return suspendCoroutine { continuation -> 21 | HttpClient.releaseService.getRelease() 22 | .enqueue(object : Callback { 23 | override fun onResponse( 24 | call: Call, 25 | response: Response 26 | ) { 27 | if (response.isSuccessful) { 28 | continuation.resume(response.body()) 29 | } else { 30 | continuation.resume(null) 31 | } 32 | } 33 | 34 | override fun onFailure(call: Call, t: Throwable) { 35 | continuation.resume(null) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | companion object { 42 | private const val TAG = "ReleaseRequest" 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/requests/ReleaseResponse.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.requests 2 | 3 | 4 | data class ReleaseResponse( 5 | val version_code: Int?, 6 | val version_name: String?, 7 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/requests/ReleaseService.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.requests 2 | 3 | import retrofit2.Call 4 | import retrofit2.http.GET 5 | 6 | interface ReleaseService { 7 | @GET("kitkat/version.json") 8 | fun getRelease( 9 | ): Call 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lizongying/mytv0/requests/TimeResponse.kt: -------------------------------------------------------------------------------- 1 | package com.lizongying.mytv0.requests 2 | 3 | 4 | data class TimeResponse( 5 | val data: Time 6 | ) { 7 | data class Time( 8 | val t: String 9 | ) 10 | } -------------------------------------------------------------------------------- /app/src/main/res/color/switch_thumb_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/color/switch_track_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/appreciate.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/app/src/main/res/drawable/appreciate.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable/banner0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/app/src/main/res/drawable/banner0.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_heart.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_heart_empty.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_heart_empty_light.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/logo0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/app/src/main/res/drawable/logo0.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_blue_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_dark_bottom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_dark_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_dark_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_light_bottom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_white_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_white_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rounded_white_top.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/sad_cloud.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 16 | 19 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/layout/channel.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 13 | 14 | 22 | 32 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/res/layout/error.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 21 | 22 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/layout/group_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/layout/info.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 13 | 20 | 21 | 29 | 38 | 39 | 47 | 48 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /app/src/main/res/layout/list_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 18 | 19 | 33 | 34 | 47 | 48 | 58 | -------------------------------------------------------------------------------- /app/src/main/res/layout/loading.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/layout/menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 17 | 18 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout/modal.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/layout/player.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/layout/show.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/layout/time.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 13 | 14 | 22 | 32 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/raw/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 我的電視·〇 9 | 56 | 57 | 58 |

我的電視·〇

59 | 60 |
61 | 62 |
63 | 64 | 65 | 66 |
67 | 68 |
69 | 70 |
71 | 72 | 73 |
74 | 75 |
76 | 77 |
78 | 79 | 80 | 81 |
82 | 83 |
84 | 85 |
86 | 87 | 88 | 89 |
90 | 91 |
92 | 93 |
94 | 95 | 96 | 97 |
98 | 99 |
100 | 101 | 208 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh-rCN/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 我的电视·〇 3 | 换台反转 4 | 换台时显示频道号 5 | 更新应用 6 | 开机自启 7 | 赞赏作者 8 | 应用配置 9 | 默认频道 10 | 循环播放时显示视频信息 11 | 显示时间 12 | 应用启动后自动更新视频源 13 | 退出应用 14 | 默认频道号 15 | 恢复默认 16 | 远程配置 17 | 应用启动后进入我的收藏 18 | 网络代理 19 | EPG状态错误 20 | EPG请求错误 21 | 频道导入成功 22 | 频道导入错误 23 | 频道状态错误 24 | 频道格式错误 25 | 无法读取频道 26 | 频道请求错误 27 | 文件不存在 28 | 频道为空 29 | 已恢复到默认配置 30 | 无效的配置地址 31 | 授权失败 32 | 播放默认频道 33 | 播放上次频道 34 | 默认频道超出频道列表范围,已自动设置为0 35 | 上次频道超出频道列表范围,已自动设置为0 36 | 收藏模式 37 | 标准模式 38 | 再按一次退出 39 | 显示全部频道 40 | 默认频道设置成功 41 | 默认频道设置失败 42 | 代理地址设置成功 43 | 代理地址设置失败 44 | EPG设置成功 45 | EPG设置失败 46 | 开始配置频道 47 | 开始设置默认频道 48 | 我的收藏 49 | 全部频道 50 | 紧凑的菜单 51 | 时间显示秒 52 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh-rTW/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 我的電視·〇 3 | 換台反轉 4 | 換台時顯示頻道號 5 | 更新應用 6 | 開機自啟 7 | 讚賞作者 8 | 應用配置 9 | 默認頻道 10 | 循環播放時顯示視頻信息 11 | 顯示時間 12 | 應用啟動後自動更新視頻源 13 | 退出應用 14 | 默認頻道號 15 | 恢復默認 16 | 遠程配置 17 | 應用啟動後進入我的收藏 18 | 網絡代理 19 | EPG狀態錯誤 20 | EPG請求錯誤 21 | 頻道導入成功 22 | 頻道導入錯誤 23 | 頻道狀態錯誤 24 | 頻道格式錯誤 25 | 無法讀取頻道 26 | 頻道請求錯誤 27 | 文件不存在 28 | 頻道為空 29 | 已恢復到默認配置 30 | 無效的配置地址 31 | 授權失敗 32 | 播放默认頻道 33 | 播放上次頻道 34 | 默认頻道超出頻道列表範圍,已自動設置為0 35 | 上次頻道超出頻道列表範圍,已自動設置為0 36 | 收藏模式 37 | 標準模式 38 | 再按一次退出 39 | 顯示全部頻道 40 | 代理地址設定成功 41 | 代理地址設定失敗 42 | EPG設定成功 43 | EPG設定失敗 44 | 開始配置頻道 45 | 開始設置默認頻道 46 | 我的收藏 47 | 全部頻道 48 | 緊湊的菜單 49 | 時間顯示秒 50 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | #0096A6 3 | #000 4 | #FFF 5 | #FF263238 6 | #FFEEEEEE 7 | #B3EEEEEE 8 | #400096A6 9 | #B3EEEEEE 10 | #0096A6 11 | #B3EEEEEE 12 | #FF0000 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2dp 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 我的電視〇·KK 3 | Channel Reversal 4 | Show Channel Number When Switching 5 | Check for Updates 6 | Boot Startup 7 | Appreciate Author 8 | Apply Configuration 9 | Default Channel 10 | Show Video Info During Loop Playback 11 | Show Time 12 | Auto Update Video Source on App Startup 13 | Exit Application 14 | Default Channel Number 15 | Restore Default 16 | Remote Configuration 17 | Enter My Favorites on App Startup 18 | Network Proxy 19 | EPG status error 20 | EPG request error 21 | Channel import successful 22 | Channel import error 23 | Channel status error 24 | Channel format error 25 | Unable to read channel 26 | Channel request error 27 | File does not exist 28 | Channel does not exist 29 | Configuration restored to default 30 | Invalid configuration address 31 | Authorization failed 32 | Play default channel 33 | Play last channel 34 | Default channel out of range, automatically set to 0 35 | Last channel out of range, automatically set to 0 36 | Favorite Mode 37 | Standard Mode 38 | Press again to exit 39 | Show all channels 40 | Default channel set successfully 41 | Failed to set default channel 42 | Proxy set successfully 43 | Failed to set proxy 44 | EPG set successfully 45 | Failed to set EPG 46 | Start Configuring Channel 47 | Start Setting Default Channel 48 | My Favorites 49 | All Channels 50 | Compact Menu 51 | Display Seconds 52 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/xml/network.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /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.kotlin.android) apply false 5 | } -------------------------------------------------------------------------------- /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. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec: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 | desugar_jdk_libs = "2.0.4" 3 | media3 = "1.4.0" # 1.3.0: 19 java8 1.2.1: 17 media3-datasource-okhttp:1.2.1:21 4 | nanohttpd = "2.3.1" 5 | gua64 = "1.4.5" 6 | recyclerview = "1.3.2" 7 | zxing = "3.5.3" 8 | glide = "4.16.0" # java7 9 | 10 | gson = "2.10.1" # 19:2.10.1 #api17:2.9.1 11 | okhttp = "3.12.13" # 19: 3.12.13 12 | retrofit = "2.6.4" # 21:2.9.0 17:2.6.4 13 | conscrypt = "2.5.2" 14 | 15 | work = "2.9.0" 16 | core_ktx = "1.13.1" #api17:1.12.0 17 | lifecycle = "2.8.4" 18 | constraintlayout = "2.1.4" 19 | serialization = "1.6.3" 20 | coroutines = "1.8.1" 21 | 22 | android-gradle-plugin = "8.3.2" 23 | kotlin-android = "2.0.0" 24 | appcompat = "1.6.1" #api17+: 1.6.1 25 | 26 | [libraries] 27 | desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } 28 | 29 | media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } 30 | media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } 31 | media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" } 32 | media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3" } 33 | media3-exoplayer-rtsp = { module = "androidx.media3:media3-exoplayer-rtsp", version.ref = "media3" } 34 | media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } 35 | 36 | nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" } 37 | gua64 = { module = "io.github.lizongying:gua64", version.ref = "gua64" } 38 | zxing = { module = "com.google.zxing:core", version.ref = "zxing" } 39 | glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } 40 | 41 | gson = { module = "com.google.code.gson:gson", version.ref = "gson" } 42 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } 43 | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } 44 | converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } 45 | conscrypt = { module = "org.conscrypt:conscrypt-android", version.ref = "conscrypt" } 46 | okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } 47 | 48 | serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } 49 | coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } 50 | constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } 51 | appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } 52 | recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } 53 | core-ktx = { module = "androidx.core:core-ktx", version.ref = "core_ktx" } 54 | lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } 55 | 56 | [plugins] 57 | android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } 58 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin-android" } 59 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Dec 14 14:50:34 HKT 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-rc-1-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /history.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | in_changelog=false 4 | 5 | while IFS= read -r line; do 6 | if [[ "$line" == "## "* ]]; then 7 | continue 8 | fi 9 | 10 | if [[ $in_changelog == false ]] && [[ "$line" == "### "* ]]; then 11 | in_changelog=true 12 | continue 13 | fi 14 | 15 | if [[ $in_changelog == true ]] && [[ "$line" == "### "* ]]; then 16 | break 17 | fi 18 | 19 | echo "$line" 20 | done < HISTORY.md 21 | -------------------------------------------------------------------------------- /screenshots/Screenshot_20240810_151748.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/screenshots/Screenshot_20240810_151748.png -------------------------------------------------------------------------------- /screenshots/Screenshot_20240813_232847.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/screenshots/Screenshot_20240813_232847.png -------------------------------------------------------------------------------- /screenshots/Screenshot_20240813_232900.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/screenshots/Screenshot_20240813_232900.png -------------------------------------------------------------------------------- /screenshots/appreciate.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/screenshots/appreciate.jpeg -------------------------------------------------------------------------------- /screenshots/zfb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrichv/my-tv-0/aa7d08e6bb1b58e01cd1edaf010bddb0f1ca3832/screenshots/zfb.jpg -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | 16 | rootProject.name = "My TV 0" 17 | include(":app") 18 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | {"version_code": 16975627, "version_name": "v1.3.7-kk1"} 2 | --------------------------------------------------------------------------------