first commit

This commit is contained in:
Lsong 2024-08-21 10:08:05 +08:00
commit 2c59a4e06d
95 changed files with 6184 additions and 0 deletions

51
.github/workflows/android.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: Android CI
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew assembleDebug
- name: Run tests
run: ./gradlew test
- name: Set current date as env variable
run: echo "DATE=$(date +'%Y-%m-%d')" >> $GITHUB_ENV
- name: Get repository name
run: echo "REPO_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV
- name: Rename APK
run: |
mkdir -p ./artifacts
mv ./app/build/outputs/apk/debug/app-debug.apk ./artifacts/${{ env.REPO_NAME }}-debug-${{ env.DATE }}.apk
- name: Upload Release
uses: softprops/action-gh-release@v1
if: github.event_name != 'pull_request'
with:
tag_name: nightly-${{ env.DATE }}
name: Nightly Build ${{ env.DATE }}
files: ./artifacts/${{ env.REPO_NAME }}-debug-${{ env.DATE }}.apk
draft: false
prerelease: true

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/deploymentTargetDropDown.xml
/.idea/git_toolbox_prj.xml
/.idea/deploymentTargetSelector.xml
/.idea/GrepConsole.xml
/.idea/material_theme_project_new.xml
/.idea/other.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
.kotlin/*
app/debug/*
*.apk

5
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,5 @@
# 默认忽略的文件
/shelf/
/workspace.xml
./deploymentTargetSelector.xml
/git_toolbox_blame.xml

1
.idea/.name generated Normal file
View File

@ -0,0 +1 @@
DuckTV

40
.idea/appInsightsSettings.xml generated Normal file
View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Android Vitals">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="me.lsong.files" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="SEVEN_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

131
.idea/codeStyles/Project.xml generated Normal file
View File

@ -0,0 +1,131 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_ADD_SPACE" value="true" />
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
<option name="BLOCK_COMMENT_ADD_SPACE" value="true" />
<option name="LINE_COMMENT_ADD_SPACE_ON_REFORMAT" value="true" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

6
.idea/encodings.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="PROJECT" charset="UTF-8" />
</component>
</project>

19
.idea/gradle.xml generated Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,70 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AndroidLintUnsafeImplicitIntentLaunch" enabled="false" level="ERROR" enabled_by_default="false" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

6
.idea/kotlinc.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" />
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

68
.idea/runConfigurations/app.xml generated Normal file
View File

@ -0,0 +1,68 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="app" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false">
<module name="DuckTV.app.main" />
<option name="DEPLOY" value="true" />
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
<option name="DEPLOY_AS_INSTANT" value="false" />
<option name="ARTIFACT_NAME" value="" />
<option name="PM_INSTALL_OPTIONS" value="" />
<option name="ALL_USERS" value="false" />
<option name="ALWAYS_INSTALL_WITH_PM" value="false" />
<option name="CLEAR_APP_STORAGE" value="false" />
<option name="DYNAMIC_FEATURES_DISABLED_LIST" value="" />
<option name="ACTIVITY_EXTRA_FLAGS" value="" />
<option name="MODE" value="default_activity" />
<option name="CLEAR_LOGCAT" value="true" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Hybrid>
<Java>
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Java>
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
<option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
<option name="DEBUG_SANDBOX_SDK" value="false" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Java/Kotlin Method Sample (legacy)" />
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
</Profilers>
<option name="DEEP_LINK" value="" />
<option name="ACTIVITY_CLASS" value="" />
<option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
<option name="SKIP_ACTIVITY_VALIDATION" value="false" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

BIN
Ducky.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 KiB

BIN
DuckyTransparent.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

5
LICENSE Normal file
View File

@ -0,0 +1,5 @@
# LICENSE
This project is licensed under GPLv3 with additional constraints. For more details, please refer to the [LICENSE](https://github.com/song940/.github/blob/main/LICENSE.md).
This project is based on [@yaoxieyoulei/mytv-android](https://github.com/yaoxieyoulei/mytv-android), which is licensed under the [MIT LICENSE](https://github.com/yaoxieyoulei/mytv-android/blob/main/LICENSE). Special thanks to [@yaoxieyoulei](https://github.com/yaoxieyoulei) for their work.

18
README.md Normal file
View File

@ -0,0 +1,18 @@
<h1 align="center">
<img src="./icon.png" alt="DuckTV Icon" width="200">
<br>DuckTV<br>
</h1>
<p align="center"><em>:tv: Simple IPTV App for Android TV</em></p>
<p align="center">
<a href='https://play.google.com/store/apps/details?id=me.lsong.mytv'>
<img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' width='250' />
</a>
</p>
<p align="center">
<a href='https://github.com/song940/mytv-android/releases'>Get nightly build on GitHub Releases</a>
</p>
----

3
app/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/build
keystore.jks
/release

123
app/build.gradle.kts Normal file
View File

@ -0,0 +1,123 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.serialization)
}
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "me.lsong.mytv"
compileSdk = 34
defaultConfig {
applicationId = "me.lsong.mytv"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.4.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
ndk {
abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86_64"))
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
signingConfigs {
create("release") {
storeFile =
file(System.getenv("KEYSTORE") ?: keystoreProperties["storeFile"] ?: "keystore.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
?: keystoreProperties.getProperty("storePassword")
keyAlias = System.getenv("KEY_ALIAS") ?: keystoreProperties.getProperty("keyAlias")
keyPassword =
System.getenv("KEY_PASSWORD") ?: keystoreProperties.getProperty("keyPassword")
}
}
buildTypes {
getByName("release") {
signingConfig = signingConfigs.getByName("release")
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.androidx.material.icons.extended)
// TV Compose
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material)
// 播放器
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.rtsp)
// 序列化
implementation(libs.kotlinx.serialization)
// 网络请求
implementation(libs.okhttp)
implementation(libs.androidasync)
// 二维码
implementation(libs.qrose)
implementation(libs.coil.compose)
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

Binary file not shown.

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".MyTVApplication"
android:allowBackup="true"
android:banner="@drawable/ic_banner"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.MyTV"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.MyTV">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,149 @@
package me.lsong.mytv
import android.app.PictureInPictureParams
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Rational
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.IntRange
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.debounce
import me.lsong.mytv.ui.MainScreen
import me.lsong.mytv.ui.components.LeanbackPadding
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.HttpServer
import me.lsong.mytv.utils.Settings
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
// 隐藏状态栏、导航栏
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, window.decorView).let { insetsController ->
insetsController.hide(WindowInsetsCompat.Type.statusBars())
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
// 屏幕常亮
val doubleBackPressedExitState = rememberLeanbackDoubleBackPressedExitState()
BackHandler {
if (doubleBackPressedExitState.allowExit) {
finish()
exitProcess(0)
} else {
doubleBackPressedExitState.backPress()
Toast.makeText(applicationContext, "再按一次退出", Toast.LENGTH_SHORT).show()
}
}
LeanbackTheme {
MainScreen()
}
}
// Check if the device is a TV
if (isTVDevice()) {
// No need to force orientation for TV
} else {
// Force landscape mode on non-TV devices
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
}
HttpServer.start(applicationContext)
}
private fun isTVDevice(): Boolean {
return (packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
override fun onUserLeaveHint() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
// if (!Settings.uiPipMode) return
// enterPictureInPictureMode(
// PictureInPictureParams.Builder()
// .setAspectRatio(Rational(16, 9))
// .build()
// )
super.onUserLeaveHint()
}
}
/**
* 退出应用二次确认
*/
class LeanbackDoubleBackPressedExitState internal constructor(
@IntRange(from = 0)
private val resetSeconds: Int,
) {
private var _allowExit by mutableStateOf(false)
val allowExit get() = _allowExit
fun backPress() {
_allowExit = true
channel.trySend(resetSeconds)
}
private val channel = Channel<Int>(Channel.CONFLATED)
@OptIn(FlowPreview::class)
suspend fun observe() {
channel.consumeAsFlow()
.debounce { it.toLong() * 1000 }
.collect { _allowExit = false }
}
}
/**
* 退出应用二次确认状态
*/
@Composable
fun rememberLeanbackDoubleBackPressedExitState(@IntRange(from = 0) resetSeconds: Int = 2) =
remember { LeanbackDoubleBackPressedExitState(resetSeconds = resetSeconds) }
.also { LaunchedEffect(it) { it.observe() } }
val LeanbackParentPadding = PaddingValues(vertical = 12.dp, horizontal = 24.dp)
@Composable
fun rememberLeanbackChildPadding(direction: LayoutDirection = LocalLayoutDirection.current) =
remember {
LeanbackPadding(
start = LeanbackParentPadding.calculateStartPadding(direction) + 8.dp,
top = LeanbackParentPadding.calculateTopPadding(),
end = LeanbackParentPadding.calculateEndPadding(direction) + 8.dp,
bottom = LeanbackParentPadding.calculateBottomPadding()
)
}

View File

@ -0,0 +1,12 @@
package me.lsong.mytv
import android.app.Application
import me.lsong.mytv.utils.Settings
class MyTVApplication : Application() {
override fun onCreate() {
super.onCreate()
UnsafeTrustManager.enableUnsafeTrustManager()
Settings.init(applicationContext)
}
}

View File

@ -0,0 +1,45 @@
package me.lsong.mytv
import android.annotation.SuppressLint
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
// 防止部分直播源链接证书不被信任
@SuppressLint("CustomX509TrustManager")
class UnsafeTrustManager : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
// Do nothing and trust all certificates
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
// Do nothing and trust all certificates
}
override fun getAcceptedIssuers(): Array<X509Certificate> {
return emptyArray()
}
companion object {
fun enableUnsafeTrustManager() {
try {
val trustAllCerts = arrayOf<TrustManager>(UnsafeTrustManager())
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustAllCerts, SecureRandom())
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true }
} catch (e: NoSuchAlgorithmException) {
e.printStackTrace()
} catch (e: KeyManagementException) {
e.printStackTrace()
}
}
}
}

View File

@ -0,0 +1,126 @@
package me.lsong.mytv.epg
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
import me.lsong.mytv.iptv.TVChannel
import me.lsong.mytv.epg.EpgChannel.Companion.currentProgrammes
import me.lsong.mytv.epg.EpgProgramme.Companion.isLive
/**
* 频道节目单
*/
@Serializable
data class EpgChannel(
val id: String = "",
/**
* 频道名称
*/
val title: String = "",
/**
* 节目列表
*/
val programmes: List<EpgProgramme> = emptyList(),
) {
companion object {
/**
* 获取本频道的当前节目和下一个节目
*/
fun EpgChannel.currentProgrammes(): EpgProgrammeCurrent? {
val currentProgramme = programmes.firstOrNull { it.isLive() } ?: return null
return EpgProgrammeCurrent(
now = currentProgramme,
next = programmes.indexOf(currentProgramme).let { index ->
if (index + 1 < programmes.size) programmes[index + 1]
else null
},
)
}
}
}
@Immutable
data class EpgList(
val value: List<EpgChannel> = emptyList(),
) : List<EpgChannel> by value {
companion object {
fun EpgList.getEpgChannel(channel: TVChannel): EpgChannel? {
return (
firstOrNull{ it.id == channel.name } ?:
firstOrNull{ it.title == channel.title } ?:
firstOrNull{ it.title == channel.name }
)
}
/**
* 获取指定频道的当前节目和下一个节目
*/
fun EpgList.currentProgrammes(channel: TVChannel): EpgProgrammeCurrent? {
return getEpgChannel(channel)?.currentProgrammes()
}
}
}
/**
* 频道节目
*/
@Serializable
data class EpgProgramme(
val channelId: String = "",
/**
* 开始时间时间戳
*/
val startAt: Long = 0,
/**
* 结束时间时间戳
*/
val endAt: Long = 0,
/**
* 节目名称
*/
val title: String = "",
) {
companion object {
/**
* 是否正在直播
*/
fun EpgProgramme.isLive() = System.currentTimeMillis() in startAt..<endAt
/**
* 节目进度
*/
fun EpgProgramme.progress() =
(System.currentTimeMillis() - startAt).toFloat() / (endAt - startAt)
}
}
/**
* 当前节目/下一个节目
*/
data class EpgProgrammeCurrent(
/**
* 当前正在播放
*/
val now: EpgProgramme? = null,
/**
* 稍后播放
*/
val next: EpgProgramme? = null,
) {
companion object {
val EXAMPLE = EpgProgrammeCurrent(
now = EpgProgramme(
startAt = 0,
endAt = 0,
title = "实况录像-2023/2024赛季中国男子篮球职业联赛季后赛12进8第五场",
),
next = null,
)
}
}

View File

@ -0,0 +1,104 @@
package me.lsong.mytv.epg
import android.util.Log
import android.util.Xml
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.xmlpull.v1.XmlPullParser
import me.lsong.mytv.epg.fetcher.EpgFetcher
import java.io.StringReader
import java.text.SimpleDateFormat
import java.util.Locale
/**
* 节目单获取
*/
class EpgRepository {
/**
* 解析节目单xml
*/
private fun parseFromXml(xmlString: String): EpgList {
val parser: XmlPullParser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(StringReader(xmlString))
val epgMap = mutableMapOf<String, EpgChannel>()
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
if (parser.name == "channel") {
val channelId = parser.getAttributeValue(null, "id")
parser.nextTag()
val channelDisplayName = parser.nextText()
val channel = EpgChannel(
id = channelId,
title = channelDisplayName,
)
// Log.d("epg", "${channel.id}, ${channel.title}")
epgMap[channelId] = channel
} else if (parser.name == "programme") {
val channelId = parser.getAttributeValue(null, "channel")
val startTime = parser.getAttributeValue(null, "start")
val stopTime = parser.getAttributeValue(null, "stop")
parser.nextTag()
val title = parser.nextText()
fun parseTime(time: String): Long {
if (time.length < 14) return 0
return SimpleDateFormat("yyyyMMddHHmmss Z", Locale.getDefault()).parse(time)?.time ?: 0
}
val programme = EpgProgramme(
channelId = channelId,
startAt = parseTime(startTime),
endAt = parseTime(stopTime),
title = title,
)
if (epgMap.containsKey(channelId)) {
// Log.d("epg", "${programme.channelId}, ${programme.title}")
epgMap[channelId] = epgMap[channelId]!!.copy(
programmes = epgMap[channelId]!!.programmes + listOf(programme)
)
}
}
}
}
eventType = parser.next()
}
Log.i("epg","解析节目单完成,共${epgMap.size}个频道")
return EpgList(epgMap.values.toList())
}
private suspend fun fetchXml(url: String): String = withContext(Dispatchers.IO) {
Log.d("epg", "获取远程节目单xml: $url")
val client = OkHttpClient()
val request = Request.Builder().url(url).build()
try {
with(client.newCall(request).execute()) {
if (!isSuccessful) {
throw Exception("获取远程节目单xml失败: $code")
}
val fetcher = EpgFetcher.instances.first { it.isSupport(url) }
return@with fetcher.fetch(this)
}
} catch (ex: Exception) {
throw Exception("获取远程节目单xml失败请检查网络连接", ex)
}
}
suspend fun getEpgList(xmlUrl: String): EpgList = withContext(Dispatchers.Default) {
try {
val xmlString = fetchXml(xmlUrl)
return@withContext parseFromXml(xmlString)
} catch (ex: Exception) {
Log.e("epg", "获取节目单失败", ex)
throw Exception(ex)
}
}
}

View File

@ -0,0 +1,13 @@
package me.lsong.mytv.epg.fetcher
import okhttp3.Response
class DefaultEpgFetcher : EpgFetcher {
override fun isSupport(url: String): Boolean {
return true
}
override fun fetch(response: Response): String {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
}
}

View File

@ -0,0 +1,26 @@
package me.lsong.mytv.epg.fetcher
import okhttp3.Response
/**
* 节目单获取接口
*/
interface EpgFetcher {
/**
* 是否支持该格式
*/
fun isSupport(url: String): Boolean
/**
* 获取节目单
*/
fun fetch(response: Response): String
companion object {
val instances = listOf(
XmlEpgFetcher(),
XmlGzEpgFetcher(),
DefaultEpgFetcher(),
)
}
}

View File

@ -0,0 +1,13 @@
package me.lsong.mytv.epg.fetcher
import okhttp3.Response
class XmlEpgFetcher : EpgFetcher {
override fun isSupport(url: String): Boolean {
return url.endsWith(".xml")
}
override fun fetch(response: Response): String {
return response.body!!.string()
}
}

View File

@ -0,0 +1,27 @@
package me.lsong.mytv.epg.fetcher
import okhttp3.Response
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
class XmlGzEpgFetcher : EpgFetcher {
override fun isSupport(url: String): Boolean {
return url.endsWith(".gz")
}
override fun fetch(response: Response): String {
val gzData = response.body!!.bytes()
val stringBuilder = StringBuilder()
GZIPInputStream(ByteArrayInputStream(gzData)).use { gzipInputStream ->
BufferedReader(InputStreamReader(gzipInputStream)).use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
stringBuilder.append(line).append("\n")
}
}
}
return stringBuilder.toString()
}
}

View File

@ -0,0 +1,230 @@
package me.lsong.mytv.iptv
import android.util.Log
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import me.lsong.mytv.epg.EpgChannel
import me.lsong.mytv.epg.EpgList
import me.lsong.mytv.epg.EpgRepository
import me.lsong.mytv.utils.Constants
import me.lsong.mytv.utils.Settings
import okhttp3.OkHttpClient
import okhttp3.Request
// 接口定义
interface TVProvider {
suspend fun load()
fun groups(): TVGroupList
suspend fun epg(): EpgList
}
// 数据类定义
@Immutable
data class TVSource(
val tvgId: String? = null,
val tvgLogo: String? = null,
val tvgName: String? = null,
val groupTitle: String? = null,
val title: String,
val url: String
) {
val name: String get() = tvgName ?: tvgId ?: title
companion object {
val EXAMPLE = TVSource(
tvgId = "cctv1",
tvgName = "cctv1",
tvgLogo = "https://live.fanmingming.com/tv/CCTV1.png",
title = "CCTV-1",
groupTitle = "央视",
url = "https://pi.0472.org/chc/ym.m3u8"
)
}
}
@Immutable
data class TVChannel(
val name: String = "",
val title: String = "",
val sources: List<TVSource> = emptyList()
) {
val logo: String? get() = sources.firstNotNullOfOrNull { it.tvgLogo }
val groupTitle: String? get() = sources.firstNotNullOfOrNull { it.groupTitle }
val urls: List<String> get() = sources.map { it.url }
companion object {
val EXAMPLE = TVChannel(
title = "测试频道",
sources = listOf(TVSource.EXAMPLE)
)
}
}
@Immutable
data class TVGroup(
val title: String = "",
val channels: TVChannelList = TVChannelList()
) {
companion object {
val EXAMPLE = TVGroup(
title = "测试分组",
channels = TVChannelList(List(10) { TVChannel.EXAMPLE })
)
}
}
@Immutable
data class TVGroupList(val value: List<TVGroup> = emptyList()) : List<TVGroup> by value {
companion object {
val EXAMPLE = TVGroupList(List(5) { TVGroup.EXAMPLE.copy(title = "Group $it") })
fun TVGroupList.findGroupIndex(channel: TVChannel) =
indexOfFirst { it.channels.contains(channel) }
fun TVGroupList.findChannelIndex(channel: TVChannel) =
flatMap { it.channels }.indexOf(channel)
val TVGroupList.channels: List<TVChannel>
get() = flatMap { it.channels }
}
}
@Immutable
data class TVChannelList(val value: List<TVChannel> = emptyList()) : List<TVChannel> by value {
companion object {
val EXAMPLE = TVChannelList(List(10) { TVChannel.EXAMPLE.copy() })
}
}
data class M3uData(
var epgUrl: String?,
val sources: List<TVSource>
)
class M3uParser {
fun parse(data: String): M3uData {
var xTvgUrl: String? = null
val channels = mutableListOf<TVSource>()
data
.trim()
.split("\r\n", "\n")
.filter { it.isNotBlank() }
.windowed(2) { (line1, line2) ->
when {
line1.startsWith("#EXTM3U") -> {
xTvgUrl = Regex("x-tvg-url=\"(.+?)\"").find(line1)?.groupValues?.get(1)?.trim()
}
line1.startsWith("#EXTINF") && !line2.startsWith("#") -> {
val title = line1.split(",").lastOrNull()?.trim() ?: return@windowed
val attributes = parseTvgAttributes(line1)
channels.add(
TVSource(
tvgId = attributes["tvg-id"],
tvgName = attributes["tvg-name"],
tvgLogo = attributes["tvg-logo"],
groupTitle = attributes["group-title"],
title = title,
url = line2.trim()
)
)
}
}
}
return M3uData(epgUrl = xTvgUrl, channels)
}
private fun parseTvgAttributes(line: String): Map<String, String> =
Regex("""(\S+?)="(.+?)"""").findAll(line)
.associate { it.groupValues[1] to it.groupValues[2].trim() }
}
class IPTVProvider(private val epgRepository: EpgRepository) : TVProvider {
private var groupList: TVGroupList = TVGroupList()
private var epgList: EpgList = EpgList()
override suspend fun load() {
val (sources, epgUrls) = fetchIPTVSources()
groupList = process(sources)
epgList = fetchEPGData(epgUrls)
}
override suspend fun epg(): EpgList = epgList
override fun groups(): TVGroupList = groupList
private suspend fun fetchIPTVSources(): Pair<List<TVSource>, List<String>> {
val allSources = mutableListOf<TVSource>()
val epgUrls = mutableListOf<String>()
val iptvUrls = Settings.iptvSourceUrls.ifEmpty { listOf(Constants.IPTV_SOURCE_URL) }
iptvUrls.forEach { url ->
val m3u = retry { getM3uChannels(sourceUrl = url) }
allSources.addAll(m3u.sources)
m3u.epgUrl?.let { epgUrls.add(it) }
}
if (epgUrls.isEmpty()) epgUrls.add(Constants.EPG_XML_URL)
return Pair(allSources, epgUrls.distinct())
}
private suspend fun fetchEPGData(epgUrls: List<String>): EpgList {
val epgChannels = mutableListOf<EpgChannel>()
epgUrls.forEach { url ->
val epg = retry { epgRepository.getEpgList(url) }
epgChannels.addAll(epg.value)
}
return EpgList(epgChannels.distinctBy { it.id })
}
private fun process(sources: List<TVSource>): TVGroupList {
val channels = sources.groupBy { it.name }
.map { (name, sources) ->
TVChannel(
name = name,
title = sources.first().title,
sources = sources,
)
}
return TVGroupList(
channels.groupBy { it.groupTitle ?: "其他" }
.map { (title, channels) -> TVGroup(title = title, channels = TVChannelList(channels)) }
)
}
private suspend fun <T> retry(fn: suspend () -> T): T {
repeat(Constants.HTTP_RETRY_COUNT) {
try {
return fn()
} catch (e: Exception) {
if (it == Constants.HTTP_RETRY_COUNT) throw e
delay(Constants.HTTP_RETRY_INTERVAL)
}
}
throw IllegalStateException("Failed to fetch data after ${Constants.HTTP_RETRY_COUNT} attempts")
}
private suspend fun request(url: String) = withContext(Dispatchers.IO) {
Log.d("request", "request start: $url")
val client = OkHttpClient()
val request = Request.Builder().url(url).build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Exception("failed: ${response.code}")
response.body?.string()?.trim() ?: throw Exception("Empty response body")
}
} catch (ex: Exception) {
val e = Exception("request failed $url", ex)
Log.d("request", "${e.message}")
throw e;
}
}
private suspend fun getM3uChannels(sourceUrl: String): M3uData {
val parser = M3uParser()
val content = request(sourceUrl)
return parser.parse(content).also {
Log.i("getM3uChannels", "解析直播源完成:${it.sources.size}个资源, $sourceUrl")
}
}
}

View File

@ -0,0 +1,364 @@
package me.lsong.mytv.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import me.lsong.mytv.R
import me.lsong.mytv.epg.EpgList
import me.lsong.mytv.epg.EpgList.Companion.currentProgrammes
import me.lsong.mytv.epg.EpgRepository
import me.lsong.mytv.iptv.IPTVProvider
import me.lsong.mytv.iptv.TVChannel
import me.lsong.mytv.iptv.TVGroupList
import me.lsong.mytv.iptv.TVGroupList.Companion.channels
import me.lsong.mytv.iptv.TVGroupList.Companion.findGroupIndex
import me.lsong.mytv.iptv.TVProvider
import me.lsong.mytv.ui.components.LeanbackVisible
import me.lsong.mytv.ui.components.MonitorScreen
import me.lsong.mytv.ui.components.MyTvMenu
import me.lsong.mytv.ui.components.MyTvMenuItem
import me.lsong.mytv.ui.components.MyTvNowPlaying
import me.lsong.mytv.ui.player.MyTvVideoScreen
import me.lsong.mytv.ui.player.rememberLeanbackVideoPlayerState
import me.lsong.mytv.ui.settings.MyTvSettingsViewModel
import me.lsong.mytv.ui.settings.SettingsScreen
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.Constants
import me.lsong.mytv.utils.handleLeanbackDragGestures
import me.lsong.mytv.utils.handleLeanbackKeyEvents
@Composable
private fun StartScreen(state: LeanbackMainUiState) {
var isSettingsVisible by remember { mutableStateOf(false) }
BackHandler(enabled = !isSettingsVisible) {
isSettingsVisible = true
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.onPreviewKeyEvent { event ->
if (event.key == Key.Menu && event.type == KeyEventType.KeyUp) {
isSettingsVisible = !isSettingsVisible
true
} else {
false
}
},
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Image(
painter = painterResource(id = R.mipmap.ic_launcher),
contentDescription = "DuckTV",
modifier = Modifier.size(96.dp)
)
Text(
text = Constants.APP_NAME,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
)
when (state) {
is LeanbackMainUiState.Loading -> {
LinearProgressIndicator(
modifier = Modifier
.widthIn(300.dp, 800.dp)
.height(8.dp)
)
state.message?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f),
modifier = Modifier.sizeIn(maxWidth = 500.dp),
)
}
}
is LeanbackMainUiState.Error -> {
state.message?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
modifier = Modifier.sizeIn(maxWidth = 500.dp),
)
}
}
else -> {} // This case should never happen
}
}
}
LeanbackVisible({ isSettingsVisible }) {
SettingsScreen()
}
}
@Composable
fun MyTvMenuWidget(
modifier: Modifier = Modifier,
epgListProvider: () -> EpgList = { EpgList() },
channelProvider: () -> TVChannel = { TVChannel() },
groupListProvider: () -> TVGroupList = { TVGroupList() },
onSelected: (TVChannel) -> Unit = {},
onSettings: () -> Unit = {},
onUserAction: () -> Unit = {}
) {
val groupList = groupListProvider()
val currentChannel = channelProvider()
val epgList = epgListProvider()
val groups = remember(groupList) {
groupList.map { group ->
MyTvMenuItem(title = group.title)
}
}
val currentGroup = remember(groupList, currentChannel) {
groups.firstOrNull { it.title == groupList[groupList.findGroupIndex(currentChannel)].title }
?: MyTvMenuItem()
}
val currentMenuItem = remember(currentChannel) {
MyTvMenuItem(
icon = currentChannel.logo ?: "",
title = currentChannel.title,
description = epgList.currentProgrammes(currentChannel)?.now?.title
)
}
val itemsProvider: (String) -> List<MyTvMenuItem> = { groupTitle ->
groupList.find { it.title == groupTitle }?.channels?.map { channel ->
MyTvMenuItem(
icon = channel.logo ?: "",
title = channel.title,
description = epgList.currentProgrammes(channel)?.now?.title
)
} ?: emptyList()
}
Row {
MyTvMenu(
groups = groups,
itemsProvider = itemsProvider,
currentGroup = currentGroup,
currentItem = currentMenuItem,
onItemSelected = { selectedItem ->
val selectedChannel = groupList.channels.first { it.title == selectedItem.title }
onSelected(selectedChannel)
},
modifier = modifier,
onSettings = onSettings,
onUserAction = onUserAction,
)
}
}
@Composable
fun MainScreen(
modifier: Modifier = Modifier,
mainViewModel: MainViewModel = viewModel(),
) {
val uiState by mainViewModel.uiState.collectAsState()
when (val state = uiState) {
is LeanbackMainUiState.Loading,
is LeanbackMainUiState.Error -> StartScreen(state)
is LeanbackMainUiState.Ready -> MainContent(
modifier = modifier,
groups = state.groups,
epgList = state.epgList,
)
}
}
sealed interface LeanbackMainUiState {
data class Loading(val message: String? = null) : LeanbackMainUiState
data class Error(val message: String? = null) : LeanbackMainUiState
data class Ready(
val groups: TVGroupList = TVGroupList(),
val epgList: EpgList = EpgList(),
) : LeanbackMainUiState
}
@Composable
fun MainContent(
modifier: Modifier = Modifier,
epgList: EpgList = EpgList(),
groups: TVGroupList = TVGroupList(),
settingsViewModel: MyTvSettingsViewModel = viewModel(),
) {
val videoPlayerState = rememberLeanbackVideoPlayerState()
val mainContentState = rememberMainContentState(
videoPlayerState = videoPlayerState,
groups = groups,
)
BackHandler (
mainContentState.isMenuVisible ||
mainContentState.isSettingsVisale ||
mainContentState.isChannelInfoVisible
) {
mainContentState.isMenuVisible = false
mainContentState.isSettingsVisale = false
mainContentState.isChannelInfoVisible = false
}
val focusRequester = remember { FocusRequester() }
MyTvVideoScreen(
state = videoPlayerState,
aspectRatioProvider = { settingsViewModel.videoPlayerAspectRatio },
showMetadataProvider = { settingsViewModel.debugShowVideoPlayerMetadata },
modifier = Modifier
.fillMaxSize()
.focusRequester(focusRequester)
.focusable()
.handleLeanbackKeyEvents(
onUp = {
if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToNext()
else mainContentState.changeCurrentChannelToPrev()
},
onDown = {
if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToPrev()
else mainContentState.changeCurrentChannelToNext()
},
onLeft = { mainContentState.changeToPrevSource() },
onRight = { mainContentState.changeToNextSource() },
onSelect = { mainContentState.showChannelInfo() },
onLongDown = { mainContentState.showMenu() },
onLongSelect = { mainContentState.showMenu() },
onSettings = { mainContentState.showMenu() },
onNumber = {},
)
.handleLeanbackDragGestures(
onSwipeDown = {
if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToNext()
else mainContentState.changeCurrentChannelToPrev()
},
onSwipeUp = {
if (settingsViewModel.iptvChannelChangeFlip) mainContentState.changeCurrentChannelToPrev()
else mainContentState.changeCurrentChannelToNext()
},
onSwipeLeft = {
mainContentState.changeToPrevSource()
},
onSwipeRight = {
mainContentState.changeToNextSource()
},
),
)
LeanbackVisible({ mainContentState.isMenuVisible && !mainContentState.isChannelInfoVisible }) {
MyTvMenuWidget(
epgListProvider = { epgList },
groupListProvider = { groups },
channelProvider = { mainContentState.currentChannel },
onSelected = { channel -> mainContentState.changeCurrentChannel(channel) },
onSettings = { mainContentState.showSettings() }
)
}
LeanbackVisible({ mainContentState.isChannelInfoVisible }) {
MyTvNowPlaying(
modifier = modifier,
epgListProvider = { epgList },
channelProvider = { mainContentState.currentChannel },
channelIndexProvider = { mainContentState.currentChannelIndex },
sourceIndexProvider = { mainContentState.currentSourceIndex },
videoPlayerMetadataProvider = { videoPlayerState.metadata },
onClose = { mainContentState.isChannelInfoVisible = false },
)
}
LeanbackVisible({ settingsViewModel.debugShowFps }) {
MonitorScreen()
}
LeanbackVisible({ mainContentState.isSettingsVisale }) {
SettingsScreen()
}
}
// MainViewModel.kt
class MainViewModel : ViewModel() {
private val providers: List<TVProvider> = listOf(
IPTVProvider(EpgRepository())
)
private val _uiState = MutableStateFlow<LeanbackMainUiState>(LeanbackMainUiState.Loading())
val uiState: StateFlow<LeanbackMainUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
refreshData()
}
}
private suspend fun refreshData() {
try {
_uiState.value = LeanbackMainUiState.Loading("Initializing providers...")
providers.forEachIndexed { index, provider ->
_uiState.value = LeanbackMainUiState.Loading("Initializing provider ${index + 1}/${providers.size}...")
provider.load()
}
val groupList = providers.flatMap { it.groups() }
val epgList = providers.map { it.epg() }.reduce { acc, epgList -> (acc + epgList) as EpgList }
_uiState.value = LeanbackMainUiState.Ready(
groups = TVGroupList(groupList),
epgList = epgList
)
} catch (error: Exception) {
_uiState.value = LeanbackMainUiState.Error(error.message)
}
}
}
@Preview(device = "id:pixel_5")
@Composable
private fun MyTvMainScreenPreview() {
LeanbackTheme {
MainScreen()
}
}

View File

@ -0,0 +1,186 @@
package me.lsong.mytv.ui
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.lsong.mytv.iptv.TVChannel
import me.lsong.mytv.iptv.TVGroupList
import me.lsong.mytv.iptv.TVGroupList.Companion.channels
import me.lsong.mytv.iptv.TVGroupList.Companion.findChannelIndex
import me.lsong.mytv.utils.Constants
import me.lsong.mytv.ui.player.LeanbackVideoPlayerState
import me.lsong.mytv.ui.player.rememberLeanbackVideoPlayerState
import me.lsong.mytv.utils.Settings
import kotlin.math.max
@Stable
class MainContentState(
coroutineScope: CoroutineScope,
private val videoPlayerState: LeanbackVideoPlayerState,
private val groups: TVGroupList,
) {
private var _currentChannel by mutableStateOf(TVChannel())
val currentChannel get() = _currentChannel
private var _currentIptvUrlIdx by mutableIntStateOf(0)
val currentSourceIndex get() = _currentIptvUrlIdx
private var _isChannelInfoVisible by mutableStateOf(false)
var isChannelInfoVisible
get() = _isChannelInfoVisible
set(value) {
_isChannelInfoVisible = value
}
private var _isMenuVisible by mutableStateOf(false)
var isMenuVisible
get() = _isMenuVisible
set(value) {
_isMenuVisible = value
}
private var _isSettingsVisale by mutableStateOf(false)
var isSettingsVisale
get() = _isSettingsVisale
set(value) {
_isSettingsVisale = value
}
val currentChannelIndex
get() = groups.findChannelIndex(_currentChannel)
init {
changeCurrentChannel(groups.channels.getOrElse(Settings.iptvLastIptvIdx) {
groups.firstOrNull()?.channels?.firstOrNull() ?: TVChannel()
})
videoPlayerState.onReady {
coroutineScope.launch {
// val name = _currentChannel.name
// val urlIdx = _currentIptvUrlIdx
}
// 记忆可播放的域名
Settings.iptvPlayableHostList += getUrlHost(_currentChannel.urls[_currentIptvUrlIdx])
}
videoPlayerState.onError {
if (_currentIptvUrlIdx < _currentChannel.urls.size - 1) {
changeCurrentChannel(_currentChannel, _currentIptvUrlIdx + 1)
}
// 从记忆中删除不可播放的域名
Settings.iptvPlayableHostList -= getUrlHost(_currentChannel.urls[_currentIptvUrlIdx])
}
videoPlayerState.onCutoff {
changeCurrentChannel(_currentChannel, _currentIptvUrlIdx)
}
}
private fun getPrevChannel(): TVChannel {
val currentIndex = groups.findChannelIndex(_currentChannel)
return groups.channels.getOrElse(currentIndex - 1) {
groups.lastOrNull()?.channels?.lastOrNull() ?: TVChannel()
}
}
private fun getNextChannel(): TVChannel {
val currentIndex = groups.findChannelIndex(_currentChannel)
return groups.channels.getOrElse(currentIndex + 1) {
groups.firstOrNull()?.channels?.firstOrNull() ?: TVChannel()
}
}
fun changeCurrentChannel(channel: TVChannel, urlIdx: Int? = null) {
// isChannelInfoVisible = false
if (channel == _currentChannel && urlIdx == null) return
if (channel == _currentChannel && urlIdx != _currentIptvUrlIdx) {
Settings.iptvPlayableHostList -= getUrlHost(_currentChannel.urls[_currentIptvUrlIdx])
}
// _isTempPanelVisible = true
_currentChannel = channel
Settings.iptvLastIptvIdx = currentChannelIndex
_currentIptvUrlIdx = if (urlIdx == null) {
// 优先从记忆中选择可播放的域名
max(0, _currentChannel.urls.indexOfFirst {
Settings.iptvPlayableHostList.contains(getUrlHost(it))
})
} else {
(urlIdx + _currentChannel.urls.size) % _currentChannel.urls.size
}
val url = channel.urls[_currentIptvUrlIdx]
Log.d("Player", "播放${channel.name}${_currentIptvUrlIdx + 1}/${_currentChannel.urls.size}: $url")
videoPlayerState.prepare(url)
}
fun changeCurrentChannelToPrev() {
changeCurrentChannel(getPrevChannel())
}
fun changeCurrentChannelToNext() {
changeCurrentChannel(getNextChannel())
}
fun changeToPrevSource(){
if (currentChannel.urls.size > 1) {
changeCurrentChannel(
channel =currentChannel,
urlIdx = currentSourceIndex - 1,
)
}
}
fun changeToNextSource(){
if (currentChannel.urls.size > 1) {
changeCurrentChannel(
channel = currentChannel,
urlIdx = currentSourceIndex + 1,
)
}
}
fun showChannelInfo() {
isMenuVisible = false
isChannelInfoVisible = true
}
fun showMenu() {
isMenuVisible = true
isChannelInfoVisible = false
}
fun showSettings() {
isMenuVisible = false
isSettingsVisale = true
isChannelInfoVisible = false
}
}
@Composable
fun rememberMainContentState(
coroutineScope: CoroutineScope = rememberCoroutineScope(),
videoPlayerState: LeanbackVideoPlayerState = rememberLeanbackVideoPlayerState(),
groups: TVGroupList = TVGroupList(),
) = remember {
MainContentState(
coroutineScope = coroutineScope,
videoPlayerState = videoPlayerState,
groups = groups,
)
}
private fun getUrlHost(url: String): String {
return url.split("://").getOrElse(1) { "" }.split("/").firstOrNull() ?: url
}

View File

@ -0,0 +1,97 @@
package me.lsong.mytv.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.lsong.mytv.iptv.TVChannel
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.isIPv6
@Composable
fun MyTvChannelInfo(
modifier: Modifier = Modifier,
channelProvider: () -> TVChannel = { TVChannel() },
channelIndexProvider: () -> Int = { 0 },
channelSourceIndexProvider: () -> Int = { 0 },
) {
val channel = channelProvider()
val channelIndex = channelIndexProvider();
val sourceIndex = channelSourceIndexProvider()
val channelNo = (channelIndex+1).toString();
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = channelNo.padStart(2, '0'),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.alignByBaseline(),
maxLines = 1,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = channel.title,
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.alignByBaseline(),
maxLines = 1,
)
Spacer(modifier = Modifier.width(6.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.labelMedium,
LocalContentColor provides LocalContentColor.current.copy(alpha = 0.8f),
) {
val textModifier = Modifier
.background(
LocalContentColor.current.copy(alpha = 0.3f),
MaterialTheme.shapes.extraSmall,
)
.padding(vertical = 2.dp, horizontal = 4.dp)
// 多线路标识
if (channel.urls.size > 1) {
Text(
text = "${sourceIndex + 1}/${channel.urls.size}",
modifier = textModifier,
)
}
// ipv4、iptv6标识
Text(
text = if (channel.urls[sourceIndex].isIPv6()) "IPV6" else "IPV4",
modifier = textModifier,
)
}
}
}
}
}
@Preview
@Composable
private fun MyTvChannelInfoPreview() {
LeanbackTheme {
MyTvChannelInfo(
channelProvider = { TVChannel.EXAMPLE },
channelSourceIndexProvider = { 1 },
)
}
}

View File

@ -0,0 +1,65 @@
package me.lsong.mytv.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.delay
import me.lsong.mytv.ui.theme.LeanbackTheme
import java.text.SimpleDateFormat
import java.util.Locale
@Composable
fun LeanbackPanelDateTime(
modifier: Modifier = Modifier,
timestamp: Long = rememberTimestamp(),
) {
val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
val dateFormat = SimpleDateFormat("MM/dd EEE", Locale.getDefault())
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = dateFormat.format(timestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = timeFormat.format(timestamp),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
@Preview
@Composable
private fun LeanbackPanelDateTimePreview() {
LeanbackTheme {
LeanbackPanelDateTime()
}
}
@Composable
private fun rememberTimestamp(): Long {
var timestamp by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
delay(1000)
timestamp = System.currentTimeMillis()
}
}
return timestamp
}

View File

@ -0,0 +1,354 @@
package me.lsong.mytv.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.list.TvLazyRow
import androidx.tv.foundation.lazy.list.items
import androidx.tv.foundation.lazy.list.rememberTvLazyListState
import androidx.tv.material3.Border
import androidx.tv.material3.CardDefaults
import kotlinx.coroutines.flow.distinctUntilChanged
import me.lsong.mytv.rememberLeanbackChildPadding
import me.lsong.mytv.epg.EpgChannel
import me.lsong.mytv.epg.EpgChannel.Companion.currentProgrammes
import me.lsong.mytv.epg.EpgProgramme
import me.lsong.mytv.epg.EpgProgramme.Companion.isLive
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.handleLeanbackKeyEvents
import java.text.SimpleDateFormat
import java.util.Locale
@Composable
private fun MyTvEpgDayItem(
modifier: Modifier = Modifier,
dayProvider: () -> String = { "" },
currentDayProvider: () -> String = { "" },
onChangeCurrentDay: () -> Unit = {},
onFocused: () -> Unit = {},
) {
val day = dayProvider()
val dateFormat = SimpleDateFormat("E MM-dd", Locale.getDefault())
val today = dateFormat.format(System.currentTimeMillis())
val tomorrow = dateFormat.format(System.currentTimeMillis() + 24 * 3600 * 1000)
val dayAfterTomorrow = dateFormat.format(System.currentTimeMillis() + 48 * 3600 * 1000)
val currentDay = currentDayProvider()
val focusRequester = remember { FocusRequester() }
var isFocused by remember { mutableStateOf(false) }
val isSelected = remember(currentDay) { currentDay == day }
LaunchedEffect(Unit) {
if (day == today) {
focusRequester.requestFocus()
onChangeCurrentDay()
}
}
androidx.tv.material3.Card(
onClick = { onChangeCurrentDay() },
modifier = modifier
.width(130.dp)
.height(50.dp)
.focusRequester(focusRequester)
.onFocusChanged {
isFocused = it.isFocused || it.hasFocus
if (isFocused) onFocused()
}
.handleLeanbackKeyEvents(
onSelect = {
onChangeCurrentDay()
true
},
),
colors = CardDefaults.colors(
containerColor = when {
isSelected -> MaterialTheme.colorScheme.onBackground
isFocused -> MaterialTheme.colorScheme.onBackground
else -> Color.Transparent
},
),
border = CardDefaults.border(
focusedBorder = Border(
border = BorderStroke(width = 2.dp, color = MaterialTheme.colorScheme.onBackground),
),
border = Border(
border = BorderStroke(
width = if (isSelected) 2.dp else 1.dp,
color = MaterialTheme.colorScheme.onBackground
)
)
),
){
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceAround,
) {
val key = day.split(" ")
Text(
text = when (day) {
today -> "今天"
tomorrow -> "明天"
dayAfterTomorrow -> "后天"
else -> key[0]
},
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = if (isSelected || isFocused) MaterialTheme.colorScheme.background
else MaterialTheme.colorScheme.onBackground,
)
Text(
text = key[1],
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
color = if (isSelected || isFocused) MaterialTheme.colorScheme.background
else MaterialTheme.colorScheme.onBackground,
)
}
}
}
@Composable
fun MyTvEpgView(
modifier: Modifier = Modifier,
epgProvider: () -> EpgChannel? = { EpgChannel() },
onUserAction: () -> Unit = {},
) {
val dateFormat = SimpleDateFormat("E MM-dd", Locale.getDefault())
val today = dateFormat.format(System.currentTimeMillis())
val epg = epgProvider()
if (epg != null && epg.programmes.isNotEmpty()) {
val programmesGroup = remember(epg) {
epg.programmes.groupBy { dateFormat.format(it.startAt) }
}
var currentDay by remember { mutableStateOf(today) }
val programmes = remember(currentDay, programmesGroup) {
programmesGroup.getOrElse(currentDay) { emptyList() }
}
val programmesListState = rememberTvLazyListState()
val daysListState = rememberTvLazyListState()
val childPadding = rememberLeanbackChildPadding()
// Find the index of the live programme
val liveIndex = remember(programmes) {
programmes.indexOfFirst { it.isLive() }
}
// Scroll to the live programme
LaunchedEffect(liveIndex) {
if (liveIndex != -1) {
programmesListState.scrollToItem(liveIndex)
}
}
LaunchedEffect(programmesListState) {
snapshotFlow { programmesListState.isScrollInProgress }
.distinctUntilChanged()
.collect { _ -> onUserAction() }
}
LaunchedEffect(daysListState) {
snapshotFlow { daysListState.isScrollInProgress }
.distinctUntilChanged()
.collect { _ -> onUserAction() }
}
Column {
Column (
modifier = modifier.padding(start = childPadding.start)
){
Text(
text = "正在播放:${epg.currentProgrammes()?.now?.title ?: "无节目"}",
maxLines = 1,
)
Text(
text = "稍后播放:${epg.currentProgrammes()?.next?.title ?: "无节目"}",
maxLines = 1,
)
}
Spacer(modifier = Modifier.padding(6.dp))
TvLazyRow(
modifier = modifier,
state = daysListState,
horizontalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(
start = childPadding.start,
end = childPadding.end,
),
) {
items(programmesGroup.keys.toList()) {
MyTvEpgDayItem(
dayProvider = { it },
currentDayProvider = { currentDay },
onChangeCurrentDay = { currentDay = it },
)
}
}
Spacer(modifier = Modifier.padding(6.dp))
TvLazyRow(
modifier = modifier,
state = programmesListState,
horizontalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(
start = childPadding.start,
end = childPadding.end,
),
) {
items(programmes) { programme ->
MyTvEpgItem(
currentProgrammeProvider = { programme },
)
}
}
}
}
}
@Composable
fun MyTvEpgItem(
modifier: Modifier = Modifier,
currentProgrammeProvider: () -> EpgProgramme,
onFocused: () -> Unit = {},
onClick: () -> Unit = {},
) {
var isFocused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val programme = currentProgrammeProvider()
val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
val isLive = programme.isLive()
LaunchedEffect(isLive) {
if (isLive) {
focusRequester.requestFocus()
}
}
androidx.tv.material3.Card(
onClick = onClick,
modifier = modifier
.width(130.dp)
.height(54.dp)
.focusRequester(focusRequester)
.onFocusChanged {
isFocused = it.isFocused || it.hasFocus
if (isFocused) onFocused()
}
.handleLeanbackKeyEvents(
onSelect = {
onClick()
true
},
),
colors = CardDefaults.colors(
containerColor = when {
isLive -> MaterialTheme.colorScheme.onBackground
isFocused -> MaterialTheme.colorScheme.onSurface
else -> Color.Transparent
},
),
border = CardDefaults.border(
focusedBorder = Border(
border = BorderStroke(width = 2.dp, color = MaterialTheme.colorScheme.onBackground),
),
border = Border(
border = BorderStroke(
width = if (isLive) 2.dp else 1.dp,
color = if (isLive) MaterialTheme.colorScheme.onBackground
else MaterialTheme.colorScheme.onBackground
)
)
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceAround,
) {
val start = timeFormat.format(programme.startAt)
val end = timeFormat.format(programme.endAt)
Text(
text = "$start ~ $end",
style = MaterialTheme.typography.labelMedium,
color = if (isLive || isFocused) MaterialTheme.colorScheme.background
else MaterialTheme.colorScheme.onBackground,
)
Text(
text = programme.title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
color = if (isLive || isFocused) MaterialTheme.colorScheme.background
else MaterialTheme.colorScheme.onBackground,
)
}
if (isLive) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "playing",
tint = MaterialTheme.colorScheme.background,
)
}
}
}
}
@Preview
@Composable
private fun EpgListPreview() {
LeanbackTheme {
MyTvEpgView(
epgProvider = {
EpgChannel(
id = "CCTV1",
programmes = List(200) { index ->
EpgProgramme(
title = "节目$index",
startAt = System.currentTimeMillis() - 3600 * 1000 * 24 * 5 + index * 3600 * 1000,
endAt = System.currentTimeMillis() - 3600 * 1000 * 24 * 5 + index * 3600 * 1000 + 3600 * 1000
)
}
)
}
)
}
}

View File

@ -0,0 +1,287 @@
package me.lsong.mytv.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.itemsIndexed
import androidx.tv.foundation.lazy.list.rememberTvLazyListState
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import kotlinx.coroutines.flow.distinctUntilChanged
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.handleLeanbackKeyEvents
data class MyTvMenuItem(
val icon: Any? = null,
val title: String = "",
val description: String? = null
)
@Composable
fun MyTvMenuItem(
modifier: Modifier = Modifier,
item: MyTvMenuItem,
isFocused: Boolean = false,
isSelected: Boolean = false,
onFocused: () -> Unit = {},
onSelected: () -> Unit = {},
onFavoriteToggle: () -> Unit = {},
focusRequester: FocusRequester = remember { FocusRequester() },
) {
LaunchedEffect(isSelected) {
if (isSelected) {
focusRequester.requestFocus()
}
}
CompositionLocalProvider(
LocalContentColor provides if (isFocused) MaterialTheme.colorScheme.background
else MaterialTheme.colorScheme.onBackground
) {
Box(
modifier = Modifier.clip(ListItemDefaults.shape().shape),
) {
androidx.tv.material3.ListItem(
modifier = modifier
.align(Alignment.Center)
.focusRequester(focusRequester)
.onFocusChanged { if (it.isFocused) onFocused() }
.align(Alignment.Center)
.handleLeanbackKeyEvents(
key = item.hashCode(),
onSelect = onSelected,
onLongSelect = onFavoriteToggle,
),
colors = ListItemDefaults.colors(
focusedContentColor = MaterialTheme.colorScheme.background,
focusedContainerColor = MaterialTheme.colorScheme.onBackground,
selectedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
onClick = onSelected,
selected = isSelected,
leadingContent = item.icon?.let { icon ->
{
when (icon) {
is ImageVector -> Icon(
imageVector = icon,
contentDescription = item.title,
modifier = Modifier.size(24.dp)
)
is String -> if (icon.isEmpty()) {
Text(
modifier = Modifier
.size(40.dp)
.background(color = MaterialTheme.colorScheme.primary)
.wrapContentHeight(align = Alignment.CenterVertically),
textAlign = TextAlign.Center,
text = item.title.take(2).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
AsyncImage(
model = icon,
contentDescription = item.title,
modifier = Modifier.size(40.dp)
)
}
else -> null
}
}
},
headlineContent = { Text(text = item.title, maxLines = 2) },
supportingContent = item.description?.let {
{
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
modifier = Modifier.alpha(0.8f),
)
}
},
)
}
}
}
@Composable
fun MyTvMenu(
groups: List<MyTvMenuItem>,
itemsProvider: (String) -> List<MyTvMenuItem>,
currentGroup: MyTvMenuItem,
currentItem: MyTvMenuItem,
onGroupFocused: (MyTvMenuItem) -> Unit = {},
onGroupSelected: (MyTvMenuItem) -> Unit = {},
onItemSelected: (MyTvMenuItem) -> Unit = {},
modifier: Modifier = Modifier,
onUserAction: () -> Unit = {},
onSettings: () -> Unit = {}
) {
var focusedGroup by remember { mutableStateOf(currentGroup) }
var focusedItem by remember { mutableStateOf(currentItem) }
var items by remember { mutableStateOf(itemsProvider(focusedGroup.title)) }
val rightListFocusRequester = remember { FocusRequester() }
Row(modifier = modifier) {
MyTvMenuItemList(
items = groups,
onSettings = onSettings,
selectedItem = focusedGroup,
onFocused = { menuGroupItem ->
focusedGroup = menuGroupItem
items = itemsProvider(menuGroupItem.title)
onGroupFocused(focusedGroup)
},
onSelected = { menuGroupItem ->
focusedGroup = menuGroupItem
items = itemsProvider(menuGroupItem.title)
focusedItem = items.firstOrNull() ?: MyTvMenuItem()
onGroupSelected(focusedGroup)
rightListFocusRequester.requestFocus()
},
onUserAction = onUserAction
)
MyTvMenuItemList(
items = items,
selectedItem = focusedItem,
onSelected = { menuItem ->
focusedItem = menuItem
onItemSelected(focusedItem)
},
onUserAction = onUserAction,
focusRequester = rightListFocusRequester
)
}
LaunchedEffect(Unit) {
rightListFocusRequester.requestFocus()
}
}
@Composable
fun MyTvMenuItemList(
items: List<MyTvMenuItem>,
selectedItem: MyTvMenuItem = items.firstOrNull() ?: MyTvMenuItem(),
onUserAction: () -> Unit = {},
onFocused: (MyTvMenuItem) -> Unit = {},
onSelected: (MyTvMenuItem) -> Unit = {},
onFavoriteToggle: (MyTvMenuItem) -> Unit = {},
focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier,
onSettings: (() -> Unit)? = null,
) {
var focusedItem by remember { mutableStateOf(selectedItem) }
val selectedIndex = remember(selectedItem, items) { items.indexOf(selectedItem) }
val itemFocusRequesterList = remember(items) { List(items.size) { FocusRequester() } }
val settingsFocusRequester = remember { FocusRequester() }
val listState = rememberTvLazyListState()
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.distinctUntilChanged()
.collect { onUserAction() }
}
LaunchedEffect(selectedItem, items) {
val index = items.indexOf(selectedItem)
listState.scrollToItem(maxOf(0, index))
}
Column(
modifier = modifier
.fillMaxHeight()
.width(250.dp)
.background(MaterialTheme.colorScheme.background.copy(0.8f))
.focusRequester(focusRequester)
) {
TvLazyColumn(
state = listState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f).align(Alignment.CenterHorizontally)
) {
itemsIndexed(items, key = { _, item -> item.hashCode() }) { index, item ->
MyTvMenuItem(
item = item,
focusRequester = itemFocusRequesterList[index],
isSelected = selectedIndex == index,
isFocused = selectedIndex == index,
onSelected = { onSelected(item) },
onFocused = {
focusedItem = item
onFocused(item)
},
onFavoriteToggle = { onFavoriteToggle(item) }
)
}
}
// Settings button at the bottom
if (onSettings != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(8.dp)
) {
MyTvMenuItem(
item = MyTvMenuItem(icon = Icons.Default.Settings, title = "Settings"),
focusRequester = settingsFocusRequester,
onSelected = onSettings
)
}
}
}
}
@Preview
@Composable
private fun MyTvMenuItemListPreview() {
LeanbackTheme {
MyTvMenuItemList(
modifier = Modifier.padding(20.dp),
items = listOf(
MyTvMenuItem(title = "Channel 1", description = "Current Program 1"),
MyTvMenuItem(title = "Channel 2", description = "Current Program 2"),
MyTvMenuItem(title = "Channel 3", description = "Current Program 3")
)
)
}
}

View File

@ -0,0 +1,175 @@
package me.lsong.mytv.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import me.lsong.mytv.rememberLeanbackChildPadding
import me.lsong.mytv.ui.theme.LeanbackTheme
@Composable
fun MonitorScreen(
modifier: Modifier = Modifier,
) {
val childPadding = rememberLeanbackChildPadding()
val fpsState = rememberFpsState()
Box(
modifier = modifier
.fillMaxSize()
.padding(start = childPadding.end, top = childPadding.top),
) {
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = childPadding.end)
.padding(horizontal = 8.dp, vertical = 4.dp)
.background(
color = androidx.tv.material3.MaterialTheme.colorScheme.background.copy(alpha = 0.8f),
shape = androidx.tv.material3.MaterialTheme.shapes.extraSmall,
)
) {
LeanbackMonitorFps(fpsProvider = { fpsState.current })
Spacer(modifier = Modifier.height(4.dp))
LeanbackMonitorFpsBar(fpsListProvider = { fpsState.history })
}
}
}
data class FpsState(
val current: Int = 0,
val history: ImmutableList<Int> = persistentListOf(),
)
@Composable
private fun rememberFpsState(): FpsState {
var state by remember { mutableStateOf(FpsState()) }
var fpsCount by remember { mutableIntStateOf(0) }
var lastUpdate by remember { mutableLongStateOf(0L) }
LaunchedEffect(Unit) {
while (true) {
withFrameMillis { ms ->
fpsCount++
if (fpsCount == 5) {
val fps = (5_000 / (ms - lastUpdate)).toInt()
state = state.copy(
current = fps,
history = (state.history.takeLast(30) + fps).toImmutableList(),
)
lastUpdate = ms
fpsCount = 0
}
}
}
}
return state
}
@Composable
private fun LeanbackMonitorFps(
modifier: Modifier = Modifier,
fpsProvider: () -> Int = { 0 },
) {
val fps = fpsProvider()
Text(
modifier = modifier,
text = "FPS: $fps",
style = MaterialTheme.typography.bodyLarge,
)
}
@Preview
@Composable
private fun LeanbackMonitorFpsPreview() {
LeanbackTheme {
LeanbackMonitorFps(
fpsProvider = { 60 }
)
}
}
@Composable
private fun LeanbackMonitorFpsBar(
modifier: Modifier = Modifier,
fpsListProvider: () -> ImmutableList<Int> = { persistentListOf() },
) {
val fpsList = fpsListProvider()
Canvas(
modifier = modifier
.width(140.dp)
.height(40.dp),
) {
val barWidth = size.width / 60 // 每个柱状条的宽度
val barSpacing = 2.dp.toPx() // 柱状条之间的间距
val maxBarHeight = size.height // 柱状图的最大高度
for (i in fpsList.indices) {
val fps = fpsList[i]
val barHeight =
(fps.coerceAtMost(60) * maxBarHeight / 60) // 柱状条的高度,最大为最大高度的一半
val x = i * (barWidth + barSpacing)
val y = size.height - barHeight
val rect = Rect(x, y, x + barWidth, size.height)
val color = when (fps) {
in 0..30 -> Color(0xfff44336)
in 31..45 -> Color(0xffffeb3b)
else -> Color(0xff00a2ff)
}
drawRect(color, topLeft = rect.topLeft, size = rect.size)
}
}
}
@Preview
@Composable
private fun LeanbackMonitorFpsBarPreview() {
LeanbackTheme {
LeanbackMonitorFpsBar(
fpsListProvider = {
List(30) { it * 2 }.toImmutableList()
}
)
}
}
@Preview
@Composable
private fun LeanbackMonitorScreenPreview() {
LeanbackTheme {
MonitorScreen()
}
}

View File

@ -0,0 +1,90 @@
package me.lsong.mytv.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.lsong.mytv.rememberLeanbackChildPadding
import me.lsong.mytv.epg.EpgList
import me.lsong.mytv.epg.EpgList.Companion.getEpgChannel
import me.lsong.mytv.iptv.TVChannel
import me.lsong.mytv.ui.player.LeanbackVideoPlayer
import me.lsong.mytv.ui.theme.LeanbackTheme
@Composable
fun MyTvNowPlaying(
modifier: Modifier = Modifier,
epgListProvider: () -> EpgList = { EpgList() },
channelProvider: () -> TVChannel = { TVChannel() },
channelIndexProvider: () -> Int = { 0 },
sourceIndexProvider: () -> Int = { 0 },
videoPlayerMetadataProvider: () -> LeanbackVideoPlayer.Metadata = { LeanbackVideoPlayer.Metadata() },
onClose: () -> Unit = {},
) {
val childPadding = rememberLeanbackChildPadding()
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f))
.pointerInput(Unit) { detectTapGestures(onTap = { onClose() }) },
) {
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(bottom = childPadding.bottom),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
MyTvChannelInfo(
modifier = modifier
.padding(
start = childPadding.start,
end = childPadding.end
),
channelProvider = channelProvider,
channelIndexProvider = channelIndexProvider,
channelSourceIndexProvider = sourceIndexProvider,
)
val epg = epgListProvider().getEpgChannel(channelProvider())
if (epg != null) {
MyTvEpgView(
modifier = modifier,
epgProvider = { epg },
)
}
MyTvPlayerInfo(
modifier = modifier.padding(start = childPadding.start, bottom = childPadding.bottom),
metadataProvider = videoPlayerMetadataProvider
)
}
Column (
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = childPadding.top, end = childPadding.end)
) {
LeanbackPanelDateTime()
}
}
}
@Preview(device = "id:Android TV (720p)")
@Composable
private fun MyTvNowPlayingPreview() {
LeanbackTheme {
MyTvNowPlaying(
channelProvider = { TVChannel.EXAMPLE },
sourceIndexProvider = { 0 },
)
}
}

View File

@ -0,0 +1,12 @@
package me.lsong.mytv.ui.components
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
@Immutable
data class LeanbackPadding(
val start: Dp,
val top: Dp,
val end: Dp,
val bottom: Dp,
)

View File

@ -0,0 +1,121 @@
package me.lsong.mytv.ui.components
import android.net.TrafficStats
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import me.lsong.mytv.ui.player.LeanbackVideoPlayer
import me.lsong.mytv.ui.theme.LeanbackTheme
import java.text.DecimalFormat
@Composable
fun MyTvPlayerInfo(
modifier: Modifier = Modifier,
metadataProvider: () -> LeanbackVideoPlayer.Metadata = { LeanbackVideoPlayer.Metadata() },
) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodyLarge,
LocalContentColor provides MaterialTheme.colorScheme.onBackground
) {
Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
PlayerInfoResolution(
resolutionProvider = {
val metadata = metadataProvider()
metadata.videoWidth to metadata.videoHeight
}
)
PanelPlayerInfoNetSpeed()
}
}
}
@Composable
private fun PlayerInfoResolution(
modifier: Modifier = Modifier,
resolutionProvider: () -> Pair<Int, Int> = { 0 to 0 },
) {
val resolution = resolutionProvider()
Text(
text = "分辨率:${resolution.first}×${resolution.second}",
modifier = modifier,
)
}
@Composable
private fun PanelPlayerInfoNetSpeed(
modifier: Modifier = Modifier,
netSpeed: Long = rememberNetSpeed(),
) {
Text(
text = if (netSpeed < 1024 * 999) "网速:${netSpeed / 1024}KB/s"
else "网速:${DecimalFormat("#.#").format(netSpeed / 1024 / 1024f)}MB/s",
modifier = modifier,
)
}
@Composable
private fun rememberNetSpeed(): Long {
var netSpeed by remember { mutableLongStateOf(0) }
LaunchedEffect(Unit) {
var lastTotalRxBytes = TrafficStats.getTotalRxBytes()
var lastTimeStamp = System.currentTimeMillis()
while (true) {
delay(1000)
val nowTotalRxBytes = TrafficStats.getTotalRxBytes()
val nowTimeStamp = System.currentTimeMillis()
val speed = (nowTotalRxBytes - lastTotalRxBytes) / (nowTimeStamp - lastTimeStamp) * 1000
lastTimeStamp = nowTimeStamp
lastTotalRxBytes = nowTotalRxBytes
netSpeed = speed
}
}
return netSpeed
}
@Preview
@Composable
private fun LeanbackPanelPlayerInfoPreview() {
LeanbackTheme {
MyTvPlayerInfo(
metadataProvider = {
LeanbackVideoPlayer.Metadata(
videoWidth = 1920,
videoHeight = 1080,
)
},
)
}
}
@Preview
@Composable
private fun LeanbackPanelPlayerInfoNetSpeedPreview() {
LeanbackTheme {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PanelPlayerInfoNetSpeed()
PanelPlayerInfoNetSpeed(netSpeed = 54321)
PanelPlayerInfoNetSpeed(netSpeed = 1222 * 1222)
}
}
}

View File

@ -0,0 +1,80 @@
package me.lsong.mytv.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import io.github.alexzhirkevich.qrose.options.QrBallShape
import io.github.alexzhirkevich.qrose.options.QrFrameShape
import io.github.alexzhirkevich.qrose.options.QrPixelShape
import io.github.alexzhirkevich.qrose.options.QrShapes
import io.github.alexzhirkevich.qrose.options.circle
import io.github.alexzhirkevich.qrose.options.roundCorners
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
@Composable
fun LeanbackQrcode(
modifier: Modifier = Modifier,
text: String,
) {
Box(
modifier = modifier
.background(
color = MaterialTheme.colorScheme.onBackground,
shape = MaterialTheme.shapes.medium,
)
) {
Image(
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(10.dp),
painter = rememberQrCodePainter(
data = text,
shapes = QrShapes(
ball = QrBallShape.circle(),
darkPixel = QrPixelShape.roundCorners(),
frame = QrFrameShape.roundCorners(.25f),
),
),
contentDescription = text,
)
}
}
@Composable
fun LeanbackQrcodeDialog(
modifier: Modifier = Modifier,
text: String,
description: String? = null,
showDialogProvider: () -> Boolean = { false },
onDismissRequest: () -> Unit = {},
) {
if (showDialogProvider()) {
AlertDialog(
modifier = modifier,
properties = DialogProperties(usePlatformDefaultWidth = false),
onDismissRequest = onDismissRequest,
confirmButton = { description?.let { Text(text = description) } },
text = {
LeanbackQrcode(
text = text,
modifier = Modifier
.width(240.dp)
.height(240.dp),
)
},
)
}
}

View File

@ -0,0 +1,83 @@
package me.lsong.mytv.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.lsong.mytv.ui.player.LeanbackVideoPlayer
import me.lsong.mytv.ui.theme.LeanbackTheme
@Composable
fun LeanbackVideoPlayerMetadata(
modifier: Modifier = Modifier,
metadata: LeanbackVideoPlayer.Metadata,
) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.labelMedium,
LocalContentColor provides MaterialTheme.colorScheme.onBackground
) {
Column(
modifier = modifier
.background(
MaterialTheme.colorScheme.background.copy(alpha = 0.5f),
MaterialTheme.shapes.extraSmall,
)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Column {
Text("视频", style = MaterialTheme.typography.bodyMedium)
Column(modifier = Modifier.padding(start = 10.dp)) {
Text("编码: ${metadata.videoMimeType}")
Text("解码器: ${metadata.videoDecoder}")
Text("分辨率: ${metadata.videoWidth}x${metadata.videoHeight}")
Text("色彩: ${metadata.videoColor}")
Text("帧率: ${metadata.videoFrameRate}")
Text("比特率: ${metadata.videoBitrate / 1024} kbps")
}
}
Column {
Text("音频", style = MaterialTheme.typography.bodyMedium)
Column(modifier = Modifier.padding(start = 10.dp)) {
Text("编码: ${metadata.audioMimeType}")
Text("解码器: ${metadata.audioDecoder}")
Text("声道数: ${metadata.audioChannels}")
Text("采样率: ${metadata.audioSampleRate} Hz")
}
}
}
}
}
@Preview
@Composable
private fun LeanbackVideoMetadataPreview() {
LeanbackTheme {
LeanbackVideoPlayerMetadata(
metadata = LeanbackVideoPlayer.Metadata(
videoWidth = 1920,
videoHeight = 1080,
videoMimeType = "video/hevc",
videoColor = "BT2020/Limited range/HLG/8/8",
videoFrameRate = 25.0f,
videoBitrate = 10605096,
videoDecoder = "c2.goldfish.h264.decoder",
audioMimeType = "audio/mp4a-latm",
audioChannels = 2,
audioSampleRate = 32000,
audioDecoder = "c2.android.aac.decoder",
)
)
}
}

View File

@ -0,0 +1,13 @@
package me.lsong.mytv.ui.components
import androidx.compose.runtime.Composable
@Composable
fun LeanbackVisible(
visibleProvider: () -> Boolean = { false },
content: @Composable () -> Unit
) {
if (visibleProvider()) {
content()
}
}

View File

@ -0,0 +1,233 @@
package me.lsong.mytv.ui.player
import android.content.Context
import android.net.Uri
import android.view.SurfaceView
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DecoderReuseEvaluation
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.rtsp.RtspMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.util.EventLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.lsong.mytv.utils.Settings
import androidx.media3.common.PlaybackException as Media3PlaybackException
@OptIn(UnstableApi::class)
class LeanbackMedia3VideoPlayer(
private val context: Context,
private val coroutineScope: CoroutineScope,
) : LeanbackVideoPlayer(coroutineScope) {
private val videoPlayer = ExoPlayer.Builder(
context,
DefaultRenderersFactory(context).setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
).build().apply {
playWhenReady = true
}
private val contentTypeAttempts = mutableMapOf<Int, Boolean>()
private var updatePositionJob: Job? = null
@OptIn(UnstableApi::class)
private fun prepare(uri: Uri, contentType: Int? = null) {
val dataSourceFactory =
DefaultDataSource.Factory(context, DefaultHttpDataSource.Factory().apply {
// setUserAgent(Settings.videoPlayerUserAgent)
setConnectTimeoutMs(Settings.videoPlayerLoadTimeout.toInt())
setReadTimeoutMs(Settings.videoPlayerLoadTimeout.toInt())
setKeepPostFor302Redirects(true)
setAllowCrossProtocolRedirects(true)
})
val mediaItem = MediaItem.fromUri(uri)
val mediaSource = when (val type = contentType ?: Util.inferContentType(uri)) {
C.CONTENT_TYPE_HLS -> {
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
}
C.CONTENT_TYPE_RTSP -> {
RtspMediaSource.Factory().createMediaSource(mediaItem)
}
C.CONTENT_TYPE_OTHER -> {
ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
}
else -> {
triggerError(
PlaybackException.UNSUPPORTED_TYPE.copy(
errorCodeName = "${PlaybackException.UNSUPPORTED_TYPE.message}_$type"
)
)
null
}
}
if (mediaSource != null) {
contentTypeAttempts[contentType ?: Util.inferContentType(uri)] = true
videoPlayer.setMediaSource(mediaSource)
videoPlayer.prepare()
triggerPrepared()
}
updatePositionJob?.cancel()
updatePositionJob = null
}
private val playerListener = object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) {
triggerResolution(videoSize.width, videoSize.height)
}
override fun onPlayerError(ex: Media3PlaybackException) {
// 如果是直播加载位置错误,尝试重新播放
if (ex.errorCode == Media3PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) {
videoPlayer.seekToDefaultPosition()
videoPlayer.prepare()
}
// 当解析容器不支持时,尝试使用其他解析容器
else if (ex.errorCode == Media3PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED) {
val uri = videoPlayer.currentMediaItem?.localConfiguration?.uri
if (uri != null) {
if (contentTypeAttempts[C.CONTENT_TYPE_HLS] != true) {
prepare(uri, C.CONTENT_TYPE_HLS)
} else if (contentTypeAttempts[C.CONTENT_TYPE_OTHER] != true) {
prepare(uri, C.CONTENT_TYPE_OTHER)
} else if (contentTypeAttempts[C.CONTENT_TYPE_OTHER] != true) {
prepare(uri, C.CONTENT_TYPE_OTHER)
} else {
triggerError(PlaybackException.UNSUPPORTED_TYPE)
}
}
} else {
triggerError(
PlaybackException(ex.errorCodeName, ex.errorCode)
)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_BUFFERING) {
triggerError(null)
triggerBuffering(true)
} else if (playbackState == Player.STATE_READY) {
triggerReady()
updatePositionJob?.cancel()
updatePositionJob = coroutineScope.launch {
triggerCurrentPosition(-1)
while (true) {
triggerCurrentPosition(videoPlayer.currentPosition)
delay(1000)
}
}
}
if (playbackState != Player.STATE_BUFFERING) {
triggerBuffering(false)
}
}
}
private val metadataListener = @UnstableApi object : AnalyticsListener {
override fun onVideoInputFormatChanged(
eventTime: AnalyticsListener.EventTime,
format: Format,
decoderReuseEvaluation: DecoderReuseEvaluation?,
) {
metadata = metadata.copy(
videoMimeType = format.sampleMimeType ?: "",
videoWidth = format.width,
videoHeight = format.height,
videoColor = format.colorInfo?.toLogString() ?: "",
// TODO 帧率、比特率目前是从tag中获取有的返回空后续需要实时计算
videoFrameRate = format.frameRate,
videoBitrate = format.bitrate,
)
triggerMetadata(metadata)
}
override fun onVideoDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
initializationDurationMs: Long,
) {
metadata = metadata.copy(videoDecoder = decoderName)
triggerMetadata(metadata)
}
override fun onAudioInputFormatChanged(
eventTime: AnalyticsListener.EventTime,
format: Format,
decoderReuseEvaluation: DecoderReuseEvaluation?,
) {
metadata = metadata.copy(
audioMimeType = format.sampleMimeType ?: "",
audioChannels = format.channelCount,
audioSampleRate = format.sampleRate,
)
triggerMetadata(metadata)
}
override fun onAudioDecoderInitialized(
eventTime: AnalyticsListener.EventTime,
decoderName: String,
initializedTimestampMs: Long,
initializationDurationMs: Long,
) {
metadata = metadata.copy(audioDecoder = decoderName)
triggerMetadata(metadata)
}
}
private val eventLogger = EventLogger()
override fun initialize() {
super.initialize()
videoPlayer.addListener(playerListener)
videoPlayer.addAnalyticsListener(metadataListener)
videoPlayer.addAnalyticsListener(eventLogger)
}
override fun release() {
videoPlayer.removeListener(playerListener)
videoPlayer.removeAnalyticsListener(metadataListener)
videoPlayer.removeAnalyticsListener(eventLogger)
videoPlayer.release()
super.release()
}
@UnstableApi
override fun prepare(url: String) {
contentTypeAttempts.clear()
prepare(Uri.parse(url))
}
override fun play() {
videoPlayer.play()
}
override fun pause() {
videoPlayer.pause()
}
override fun setVideoSurfaceView(surfaceView: SurfaceView) {
videoPlayer.setVideoSurfaceView(surfaceView)
}
}

View File

@ -0,0 +1,166 @@
package me.lsong.mytv.ui.player
import android.view.SurfaceView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.lsong.mytv.utils.Settings
abstract class LeanbackVideoPlayer(
private val coroutineScope: CoroutineScope,
) {
private var loadTimeoutJob: Job? = null
private var cutoffTimeoutJob: Job? = null
private var currentPosition = -1L
protected var metadata = Metadata()
open fun initialize() {
clearAllListeners()
}
open fun release() {
clearAllListeners()
}
abstract fun prepare(url: String)
abstract fun play()
abstract fun pause()
abstract fun setVideoSurfaceView(surfaceView: SurfaceView)
private val onResolutionListeners = mutableListOf<(width: Int, height: Int) -> Unit>()
private val onErrorListeners = mutableListOf<(error: PlaybackException?) -> Unit>()
private val onReadyListeners = mutableListOf<() -> Unit>()
private val onBufferingListeners = mutableListOf<(buffering: Boolean) -> Unit>()
private val onPreparedListeners = mutableListOf<() -> Unit>()
private val onMetadataListeners = mutableListOf<(metadata: Metadata) -> Unit>()
private val onCutoffListeners = mutableListOf<() -> Unit>()
private fun clearAllListeners() {
onResolutionListeners.clear()
onErrorListeners.clear()
onReadyListeners.clear()
onBufferingListeners.clear()
onPreparedListeners.clear()
onMetadataListeners.clear()
onCutoffListeners.clear()
}
protected fun triggerResolution(width: Int, height: Int) {
onResolutionListeners.forEach { it(width, height) }
}
protected fun triggerError(error: PlaybackException?) {
onErrorListeners.forEach { it(error) }
if(error != PlaybackException.LOAD_TIMEOUT) {
loadTimeoutJob?.cancel()
loadTimeoutJob = null
}
}
protected fun triggerReady() {
onReadyListeners.forEach { it() }
loadTimeoutJob?.cancel()
}
protected fun triggerBuffering(buffering: Boolean) {
onBufferingListeners.forEach { it(buffering) }
}
protected fun triggerPrepared() {
onPreparedListeners.forEach { it() }
loadTimeoutJob?.cancel()
loadTimeoutJob = coroutineScope.launch {
delay(Settings.videoPlayerLoadTimeout)
triggerError(PlaybackException.LOAD_TIMEOUT)
}
cutoffTimeoutJob?.cancel()
cutoffTimeoutJob = null
}
protected fun triggerMetadata(metadata: Metadata) {
onMetadataListeners.forEach { it(metadata) }
}
protected fun triggerCurrentPosition(newPosition: Long) {
if (currentPosition != newPosition) {
cutoffTimeoutJob?.cancel()
cutoffTimeoutJob = coroutineScope.launch {
delay(Settings.videoPlayerLoadTimeout)
onCutoffListeners.forEach { it() }
}
}
currentPosition = newPosition
}
fun onResolution(listener: (width: Int, height: Int) -> Unit) {
onResolutionListeners.add(listener)
}
fun onError(listener: (error: PlaybackException?) -> Unit) {
onErrorListeners.add(listener)
}
fun onReady(listener: () -> Unit) {
onReadyListeners.add(listener)
}
fun onBuffering(listener: (buffering: Boolean) -> Unit) {
onBufferingListeners.add(listener)
}
fun onPrepared(listener: () -> Unit) {
onPreparedListeners.add(listener)
}
fun onMetadata(listener: (metadata: Metadata) -> Unit) {
onMetadataListeners.add(listener)
}
fun onCutoff(listener: () -> Unit) {
onCutoffListeners.add(listener)
}
data class PlaybackException(
val errorCodeName: String,
val errorCode: Int,
) : Exception(errorCodeName) {
companion object {
val UNSUPPORTED_TYPE =
PlaybackException("UNSUPPORTED_TYPE", 10002)
val LOAD_TIMEOUT =
PlaybackException("LOAD_TIMEOUT", 10003)
}
}
/** 元数据 */
data class Metadata(
/** 视频编码 */
val videoMimeType: String = "",
/** 视频宽度 */
val videoWidth: Int = 0,
/** 视频高度 */
val videoHeight: Int = 0,
/** 视频颜色 */
val videoColor: String = "",
/** 视频帧率 */
val videoFrameRate: Float = 0f,
/** 视频比特率 */
val videoBitrate: Int = 0,
/** 视频解码器 */
val videoDecoder: String = "",
/** 音频编码 */
val audioMimeType: String = "",
/** 音频通道 */
val audioChannels: Int = 0,
/** 音频采样率 */
val audioSampleRate: Int = 0,
/** 音频解码器 */
val audioDecoder: String = "",
)
}

View File

@ -0,0 +1,60 @@
package me.lsong.mytv.ui.player
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.lsong.mytv.ui.theme.LeanbackTheme
@Composable
fun LeanbackVideoPlayerErrorScreen(
modifier: Modifier = Modifier,
errorProvider: () -> String? = { null },
) {
Box(modifier = modifier.fillMaxSize()) {
val error = errorProvider()
if (error != null) {
Column(
modifier = Modifier
.align(Alignment.Center)
.background(
color = MaterialTheme.colorScheme.background.copy(alpha = 0.8f),
shape = MaterialTheme.shapes.medium,
)
.padding(horizontal = 20.dp, vertical = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "播放失败",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error,
)
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = 0.8f),
)
}
}
}
}
@Preview(device = "id:Android TV (720p)")
@Composable
private fun LeanbackVideoPlayerErrorScreenPreview() {
LeanbackTheme {
LeanbackVideoPlayerErrorScreen(
errorProvider = { "ERROR_CODE_BEHIND_LIVE_WINDOW" }
)
}
}

View File

@ -0,0 +1,139 @@
package me.lsong.mytv.ui.player
import android.view.SurfaceView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
/**
* 播放器状态
*/
@Stable
class LeanbackVideoPlayerState(
private val instance: LeanbackVideoPlayer,
private val defaultAspectRatioProvider: () -> Float? = { null },
) {
/** 视频宽高比 */
var aspectRatio by mutableFloatStateOf(16f / 9f)
/** 错误 */
var error by mutableStateOf<String?>(null)
/** 元数据 */
var metadata by mutableStateOf(LeanbackVideoPlayer.Metadata())
fun prepare(url: String) {
error = null
instance.prepare(url)
}
fun play() {
instance.play()
}
fun pause() {
instance.pause()
}
fun setVideoSurfaceView(surfaceView: SurfaceView) {
instance.setVideoSurfaceView(surfaceView)
}
private val onReadyListeners = mutableListOf<() -> Unit>()
private val onErrorListeners = mutableListOf<() -> Unit>()
private val onCutoffListeners = mutableListOf<() -> Unit>()
fun onReady(listener: () -> Unit) {
onReadyListeners.add(listener)
}
fun onError(listener: () -> Unit) {
onErrorListeners.add(listener)
}
fun onCutoff(listener: () -> Unit) {
onCutoffListeners.add(listener)
}
fun initialize() {
instance.initialize()
instance.onResolution { width, height ->
val defaultAspectRatio = defaultAspectRatioProvider()
if (defaultAspectRatio == null) {
if (width > 0 && height > 0) aspectRatio = width.toFloat() / height
} else {
aspectRatio = defaultAspectRatio
}
}
instance.onError { ex ->
error = if (ex != null) "${ex.errorCodeName}(${ex.errorCode})"
else null
if (error != null) onErrorListeners.forEach { it.invoke() }
}
instance.onReady { onReadyListeners.forEach { it.invoke() } }
instance.onBuffering { if (it) error = null }
instance.onPrepared { }
instance.onMetadata { metadata = it }
instance.onCutoff { onCutoffListeners.forEach { it.invoke() } }
}
fun release() {
onReadyListeners.clear()
onErrorListeners.clear()
instance.release()
}
}
@Composable
fun rememberLeanbackVideoPlayerState(
defaultAspectRatioProvider: () -> Float? = { null },
): LeanbackVideoPlayerState {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val coroutineScope = rememberCoroutineScope()
val state = remember {
LeanbackVideoPlayerState(
LeanbackMedia3VideoPlayer(context, coroutineScope),
defaultAspectRatioProvider = defaultAspectRatioProvider,
)
}
DisposableEffect(Unit) {
state.initialize()
onDispose {
state.release()
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
state.play()
} else if (event == Lifecycle.Event.ON_STOP) {
state.pause()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
return state
}

View File

@ -0,0 +1,59 @@
package me.lsong.mytv.ui.player
import android.view.SurfaceView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import me.lsong.mytv.rememberLeanbackChildPadding
import me.lsong.mytv.ui.components.LeanbackVideoPlayerMetadata
import me.lsong.mytv.utils.Settings
@Composable
fun MyTvVideoScreen(
modifier: Modifier = Modifier,
state: LeanbackVideoPlayerState = rememberLeanbackVideoPlayerState(),
aspectRatioProvider: () -> Settings.VideoPlayerAspectRatio,
showMetadataProvider: () -> Boolean = { false },
) {
val context = LocalContext.current
val childPadding = rememberLeanbackChildPadding()
Box(modifier = modifier.fillMaxSize()) {
AndroidView(
modifier = when (aspectRatioProvider()) {
Settings.VideoPlayerAspectRatio.ORIGINAL -> Modifier
Settings.VideoPlayerAspectRatio.ASPECT_16_9 -> Modifier.aspectRatio(16f / 9f)
Settings.VideoPlayerAspectRatio.ASPECT_4_3 -> Modifier.aspectRatio( 4f / 3f)
Settings.VideoPlayerAspectRatio.FULL_SCREEN -> {
val configuration = LocalConfiguration.current
Modifier.aspectRatio(configuration.screenWidthDp.toFloat() / configuration.screenHeightDp.toFloat())
}
}.fillMaxSize().align(Alignment.Center),
factory = { SurfaceView(context) },
update = { surfaceView -> state.setVideoSurfaceView(surfaceView) },
)
LeanbackVideoPlayerErrorScreen(
errorProvider = { state.error },
)
// Text(text = "$aspectRatio")
if (showMetadataProvider()) {
LeanbackVideoPlayerMetadata(
modifier = Modifier.padding(start = childPadding.start, top = childPadding.top),
metadata = state.metadata,
)
}
}
}

View File

@ -0,0 +1,45 @@
package me.lsong.mytv.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import me.lsong.mytv.ui.settings.components.LeanbackSettingsCategoryAbout
import me.lsong.mytv.ui.settings.components.LeanbackSettingsCategoryApp
import me.lsong.mytv.ui.settings.components.LeanbackSettingsCategoryEpg
import me.lsong.mytv.ui.settings.components.LeanbackSettingsCategoryIptv
@Composable
fun MyTvSettingsContent(
modifier: Modifier = Modifier,
focusedCategoryProvider: () -> MyTvSettingsCategories = { MyTvSettingsCategories.entries.first() },
) {
val focusedCategory = focusedCategoryProvider()
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.9f)),
) {
Column (
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
){
Text(text = focusedCategory.title, style = MaterialTheme.typography.headlineSmall)
when (focusedCategory) {
MyTvSettingsCategories.ABOUT -> LeanbackSettingsCategoryAbout()
MyTvSettingsCategories.APP -> LeanbackSettingsCategoryApp()
MyTvSettingsCategories.IPTV -> LeanbackSettingsCategoryIptv()
MyTvSettingsCategories.EPG -> LeanbackSettingsCategoryEpg()
}
}
}
}

View File

@ -0,0 +1,146 @@
package me.lsong.mytv.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.SmartDisplay
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.itemsIndexed
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.handleLeanbackKeyEvents
enum class MyTvSettingsCategories(
val icon: ImageVector,
val title: String
) {
APP(Icons.Default.SmartDisplay, "应用"),
IPTV(Icons.Default.LiveTv, "直播源"),
EPG(Icons.Default.Menu, "节目单"),
ABOUT(Icons.Default.Info, "关于"),
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MyTvSettingsCategoryList(
modifier: Modifier = Modifier,
focusedCategoryProvider: () -> MyTvSettingsCategories = { MyTvSettingsCategories.entries.first() },
onFocused: (MyTvSettingsCategories) -> Unit = {},
) {
var hasFocused = rememberSaveable { false }
TvLazyColumn(
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier.focusRestorer().fillMaxSize()
) {
itemsIndexed(MyTvSettingsCategories.entries) { index, category ->
val isSelected by remember { derivedStateOf { focusedCategoryProvider() == category } }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (index == 0 && !hasFocused) {
focusRequester.requestFocus()
hasFocused = true
}
}
MyTvSettingsCategoryItem(
modifier = Modifier
.focusRequester(focusRequester)
.fillMaxWidth(),
icon = category.icon,
title = category.title,
isSelectedProvider = { isSelected },
onFocused = { onFocused(category) },
)
}
}
}
@Composable
private fun MyTvSettingsCategoryItem(
modifier: Modifier = Modifier,
icon: ImageVector,
title: String,
isSelectedProvider: () -> Boolean = { false },
onFocused: () -> Unit = {},
) {
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var isFocused by remember { mutableStateOf(false) }
androidx.tv.material3.ListItem(
selected = isSelectedProvider(),
onClick = { },
leadingContent = { androidx.tv.material3.Icon(icon, title) },
headlineContent = { androidx.tv.material3.Text(text = title) },
modifier = modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged {
isFocused = it.isFocused || it.hasFocus
if (isFocused) {
onFocused()
}
}
.handleLeanbackKeyEvents(
onSelect = {
if (isFocused) focusManager.moveFocus(FocusDirection.Right)
else focusRequester.requestFocus()
}
),
)
}
@Preview
@Composable
private fun LeanbackSettingsCategoryItemPreview() {
LeanbackTheme {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
MyTvSettingsCategoryItem(
icon = MyTvSettingsCategories.ABOUT.icon,
title = MyTvSettingsCategories.ABOUT.title,
)
MyTvSettingsCategoryItem(
icon = MyTvSettingsCategories.ABOUT.icon,
title = MyTvSettingsCategories.ABOUT.title,
isSelectedProvider = { true },
)
}
}
}
@Preview
@Composable
private fun LeanbackSettingsCategoryListPreview() {
LeanbackTheme {
MyTvSettingsCategoryList()
}
}

View File

@ -0,0 +1,65 @@
package me.lsong.mytv.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.lsong.mytv.ui.theme.LeanbackTheme
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
var focusedCategory by remember { mutableStateOf(MyTvSettingsCategories.entries.first()) }
Box(
modifier = modifier
.fillMaxSize()
.focusRequester(focusRequester)
.background(MaterialTheme.colorScheme.surface)
.pointerInput(Unit) { detectTapGestures(onTap = { }) },
) {
Row(
// horizontalArrangement = Arrangement.spacedBy(40.dp),
) {
MyTvSettingsCategoryList(
modifier = Modifier.width(300.dp),
focusedCategoryProvider = { focusedCategory },
onFocused = { focusedCategory = it },
)
MyTvSettingsContent(
focusedCategoryProvider = { focusedCategory },
)
}
}
}
@Preview(device = "id:pixel_5")
@Composable
private fun LeanbackSettingsScreenPreview() {
LeanbackTheme {
SettingsScreen()
}
}

View File

@ -0,0 +1,117 @@
package me.lsong.mytv.ui.settings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import me.lsong.mytv.utils.Settings
class MyTvSettingsViewModel : ViewModel() {
private var _debugShowFps by mutableStateOf(Settings.debugShowFps)
var debugShowFps: Boolean
get() = _debugShowFps
set(value) {
_debugShowFps = value
Settings.debugShowFps = value
}
private var _debugShowVideoPlayerMetadata by mutableStateOf(Settings.debugShowVideoPlayerMetadata)
var debugShowVideoPlayerMetadata: Boolean
get() = _debugShowVideoPlayerMetadata
set(value) {
_debugShowVideoPlayerMetadata = value
Settings.debugShowVideoPlayerMetadata = value
}
private var _iptvChannelChangeFlip by mutableStateOf(Settings.iptvChannelChangeFlip)
var iptvChannelChangeFlip: Boolean
get() = _iptvChannelChangeFlip
set(value) {
_iptvChannelChangeFlip = value
Settings.iptvChannelChangeFlip = value
}
private var _iptvSourceUrls by mutableStateOf(Settings.iptvSourceUrls)
var iptvSourceUrls: Set<String>
get() = _iptvSourceUrls
set(value) {
_iptvSourceUrls = value
Settings.iptvSourceUrls = value
}
private var _iptvPlayableHostList by mutableStateOf(Settings.iptvPlayableHostList)
var iptvPlayableHostList: Set<String>
get() = _iptvPlayableHostList
set(value) {
_iptvPlayableHostList = value
Settings.iptvPlayableHostList = value
}
// private var _iptvSourceUrlHistoryList by mutableStateOf(SP.iptvSourceUrlHistoryList)
// var iptvSourceUrlHistoryList: Set<String>
// get() = _iptvSourceUrlHistoryList
// set(value) {
// _iptvSourceUrlHistoryList = value
// SP.iptvSourceUrlHistoryList = value
// }
private var _iptvChannelFavoriteList by mutableStateOf(Settings.iptvChannelFavoriteList)
var iptvChannelFavoriteList: Set<String>
get() = _iptvChannelFavoriteList
set(value) {
_iptvChannelFavoriteList = value
Settings.iptvChannelFavoriteList = value
}
private var _epgXmlUrlHistoryList by mutableStateOf(Settings.epgUrls)
var epgUrls: Set<String>
get() = _epgXmlUrlHistoryList
set(value) {
_epgXmlUrlHistoryList = value
Settings.epgUrls = value
}
private var _uiDensityScaleRatio by mutableFloatStateOf(Settings.uiDensityScaleRatio)
var uiDensityScaleRatio: Float
get() = _uiDensityScaleRatio
set(value) {
_uiDensityScaleRatio = value
Settings.uiDensityScaleRatio = value
}
private var _uiFontScaleRatio by mutableFloatStateOf(Settings.uiFontScaleRatio)
var uiFontScaleRatio: Float
get() = _uiFontScaleRatio
set(value) {
_uiFontScaleRatio = value
Settings.uiFontScaleRatio = value
}
// private var _uiPipMode by mutableStateOf(Settings.uiPipMode)
// var uiPipMode: Boolean
// get() = _uiPipMode
// set(value) {
// _uiPipMode = value
// Settings.uiPipMode = value
// }
private var _videoPlayerLoadTimeout by mutableLongStateOf(Settings.videoPlayerLoadTimeout)
var videoPlayerLoadTimeout: Long
get() = _videoPlayerLoadTimeout
set(value) {
_videoPlayerLoadTimeout = value
Settings.videoPlayerLoadTimeout = value
}
private var _videoPlayerAspectRatio by mutableStateOf(Settings.videoPlayerAspectRatio)
var videoPlayerAspectRatio: Settings.VideoPlayerAspectRatio
get() = _videoPlayerAspectRatio
set(value) {
_videoPlayerAspectRatio = value
Settings.videoPlayerAspectRatio = value
}
}

View File

@ -0,0 +1,119 @@
package me.lsong.mytv.ui.settings.components
import android.content.Context
import android.content.pm.PackageInfo
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn
import me.lsong.mytv.R
import me.lsong.mytv.ui.components.LeanbackQrcode
import me.lsong.mytv.ui.settings.MyTvSettingsViewModel
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.Constants
import me.lsong.mytv.utils.HttpServer
@Composable
fun LeanbackSettingsCategoryAbout(
modifier: Modifier = Modifier,
packageInfo: PackageInfo = rememberPackageInfo(),
settingsViewModel: MyTvSettingsViewModel = viewModel(),
) {
Column (
modifier = modifier.padding(5.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
TvLazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
LeanbackSettingsCategoryListItem(
leadingContent = {
Image(
painter = painterResource(id = R.mipmap.ic_launcher),
contentDescription = "DuckTV",
modifier = Modifier.size(40.dp)
)
},
headlineContent = Constants.APP_NAME,
trailingContent = packageInfo.versionName,
supportingContent = packageInfo.packageName,
)
}
item {
LeanbackSettingsCategoryListItem(
headlineContent = "显示播放器信息",
supportingContent = "显示播放器详细信息(编码、解码器、采样率等)",
trailingContent = {
Switch(
checked = settingsViewModel.debugShowVideoPlayerMetadata,
onCheckedChange = null
)
},
onSelected = {
settingsViewModel.debugShowVideoPlayerMetadata =
!settingsViewModel.debugShowVideoPlayerMetadata
},
)
}
item {
LeanbackSettingsCategoryListItem(
headlineContent = "显示FPS",
supportingContent = "在屏幕左上角显示fps和柱状图",
trailingContent = {
Switch(checked = settingsViewModel.debugShowFps, onCheckedChange = null)
},
onSelected = {
settingsViewModel.debugShowFps = !settingsViewModel.debugShowFps
},
)
}
item{
LeanbackSettingsCategoryListItem(
headlineContent = "扫码进入设置页面",
supportingContent = HttpServer.serverUrl,
trailingContent = { LeanbackQrcode(
text = HttpServer.serverUrl,
modifier = Modifier
.width(80.dp)
.height(80.dp),
)
}
)
}
}
}
}
@Composable
private fun rememberPackageInfo(context: Context = LocalContext.current): PackageInfo =
context.packageManager.getPackageInfo(context.packageName, 0)
@Preview
@Composable
private fun SettingsAboutPreview() {
LeanbackTheme {
LeanbackSettingsCategoryAbout(
packageInfo = PackageInfo().apply {
versionName = "1.0.0"
}
)
}
}

View File

@ -0,0 +1,67 @@
package me.lsong.mytv.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn
import me.lsong.mytv.ui.settings.MyTvSettingsViewModel
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.Constants
import me.lsong.mytv.utils.Settings
import me.lsong.mytv.utils.humanizeMs
import java.text.DecimalFormat
import kotlin.math.max
@Composable
fun LeanbackSettingsCategoryApp(
modifier: Modifier = Modifier,
settingsViewModel: MyTvSettingsViewModel = viewModel(),
) {
Column (
modifier = modifier.padding(5.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
TvLazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
LeanbackSettingsCategoryListItem(
headlineContent = "全局画面比例",
trailingContent = when (settingsViewModel.videoPlayerAspectRatio) {
Settings.VideoPlayerAspectRatio.ORIGINAL -> "原始"
Settings.VideoPlayerAspectRatio.ASPECT_16_9 -> "16:9"
Settings.VideoPlayerAspectRatio.ASPECT_4_3 -> "4:3"
Settings.VideoPlayerAspectRatio.FULL_SCREEN -> "自动拉伸"
},
onSelected = {
settingsViewModel.videoPlayerAspectRatio =
Settings.VideoPlayerAspectRatio.entries.let {
it[(it.indexOf(settingsViewModel.videoPlayerAspectRatio) + 1) % it.size]
}
},
)
}
}
}
}
@Preview
@Composable
private fun LeanbackSettingsCategoryAppPreview() {
Settings.init(LocalContext.current)
LeanbackTheme {
LeanbackSettingsCategoryApp(
modifier = Modifier.padding(20.dp),
settingsViewModel = MyTvSettingsViewModel(),
)
}
}

View File

@ -0,0 +1,79 @@
package me.lsong.mytv.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn
import me.lsong.mytv.ui.settings.MyTvSettingsViewModel
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.Constants.APP_NAME
import me.lsong.mytv.utils.Settings
@Composable
fun LeanbackSettingsCategoryEpg(
modifier: Modifier = Modifier,
settingsViewModel: MyTvSettingsViewModel = viewModel(),
) {
TvLazyColumn (
modifier = modifier.padding(5.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// item {
// LeanbackSettingsCategoryListItem(
// headlineContent = "节目单刷新时间阈值",
// supportingContent = "短按增加1小时长按设为0小时时间不到${settingsViewModel.epgRefreshTimeThreshold}:00节目单将不会刷新",
// trailingContent = "${settingsViewModel.epgRefreshTimeThreshold}小时",
// onSelected = {
// settingsViewModel.epgRefreshTimeThreshold =
// (settingsViewModel.epgRefreshTimeThreshold + 1) % 12
// },
// onLongSelected = {
// settingsViewModel.epgRefreshTimeThreshold = 0
// },
// )
// }
item{
Column {
Text(text = "自定义节目单", style = MaterialTheme.typography.titleMedium)
var epgUrls by remember { mutableStateOf(settingsViewModel.epgUrls) }
URLListEditor(
urls = epgUrls,
onUrlsChange = { newUrls ->
epgUrls = newUrls
settingsViewModel.epgUrls = newUrls
}
)
Text(
style = MaterialTheme.typography.bodySmall,
text = "${APP_NAME}」支持从直播源中获取 EPG 节目单 (x-tvg-url),您也可以在这里添加自定义节目单,如果没有提供,将使用默认的节目单提供商。"
)
}
}
}
}
@Preview
@Composable
private fun LeanbackSettingsCategoryEpgPreview() {
Settings.init(LocalContext.current)
LeanbackTheme {
LeanbackSettingsCategoryEpg(
modifier = Modifier.padding(20.dp),
settingsViewModel = MyTvSettingsViewModel().apply {
}
)
}
}

View File

@ -0,0 +1,196 @@
package me.lsong.mytv.ui.settings.components
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.MaterialTheme
import me.lsong.mytv.ui.settings.MyTvSettingsViewModel
import me.lsong.mytv.ui.theme.LeanbackTheme
import me.lsong.mytv.utils.Constants.APP_NAME
import me.lsong.mytv.utils.Settings
@Composable
fun URLListEditor(
urls: Set<String>,
onUrlsChange: (Set<String>) -> Unit
) {
var selectedItems by remember { mutableStateOf(setOf<String>()) }
var showDialog by remember { mutableStateOf(false) }
var inputSource by remember { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// List of URLs
LazyColumn(
modifier = Modifier
.height(200.dp)
.fillMaxWidth()
.border(1.dp, color = MaterialTheme.colorScheme.border),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(20.dp, 10.dp),
) {
itemsIndexed(urls.toList()) { _, url ->
androidx.tv.material3.ListItem(
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface,
focusedContainerColor = MaterialTheme.colorScheme.onPrimary,
),
selected = selectedItems.contains(url),
onClick = {
selectedItems = if (selectedItems.contains(url)) {
selectedItems - url
} else {
selectedItems + url
}
},
headlineContent = {
Row {
Checkbox(checked = selectedItems.contains(url), onCheckedChange = null)
Spacer(modifier = Modifier.width(10.dp))
Text(text = url, maxLines = 1)
}
}
)
}
}
if (showDialog) {
AlertDialog(
modifier = Modifier.fillMaxWidth(),
shape = androidx.compose.material3.MaterialTheme.shapes.extraSmall,
title = { Text("Input URL") },
onDismissRequest = { showDialog = false },
text = {
TextField(
value = inputSource,
modifier = Modifier.fillMaxWidth(),
onValueChange = { inputSource = it }
)
},
confirmButton = {
Button(onClick = {
onUrlsChange(urls + inputSource)
showDialog = false
inputSource = ""
}) {
Text("Confirm")
}
},
dismissButton = {
Button(onClick = { showDialog = false }) {
Text("Cancel")
}
}
)
}
Row {
IconButton(onClick = {
showDialog = true
}) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
IconButton(onClick = {
onUrlsChange(urls - selectedItems)
selectedItems = emptySet()
}) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
}
@Composable
fun LeanbackSettingsCategoryIptv(
modifier: Modifier = Modifier,
settingsViewModel: MyTvSettingsViewModel = viewModel(),
) {
TvLazyColumn (
modifier = modifier.padding(5.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// Existing items
item {
LeanbackSettingsCategoryListItem(
headlineContent = "换台反转",
supportingContent = if (settingsViewModel.iptvChannelChangeFlip) "方向键上:下一个频道;方向键下:上一个频道"
else "方向键上:上一个频道;方向键下:下一个频道",
trailingContent = {
Switch(
checked = settingsViewModel.iptvChannelChangeFlip,
onCheckedChange = null
)
},
onSelected = {
settingsViewModel.iptvChannelChangeFlip =
!settingsViewModel.iptvChannelChangeFlip
},
)
}
item {
Column {
Text(text = "IPTV 源管理", style = MaterialTheme.typography.titleMedium)
var iptvSourceUrls by remember { mutableStateOf(settingsViewModel.iptvSourceUrls) }
URLListEditor(
urls = iptvSourceUrls,
onUrlsChange = { newUrls ->
iptvSourceUrls = newUrls
settingsViewModel.iptvSourceUrls = newUrls
}
)
Text(
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
text = "${APP_NAME}」支持从多个直播源中获取频道播放地址,您也可以在这里添加自定义直播源,如果没有提供,将使用默认的直播源提供商。"
)
}
}
}
}
@Preview
@Composable
private fun MyTvSettingsPreview() {
Settings.init(LocalContext.current)
LeanbackTheme {
LeanbackSettingsCategoryIptv(
modifier = Modifier.padding(20.dp),
settingsViewModel = MyTvSettingsViewModel().apply {
iptvSourceUrls = setOf(
"https://iptv-org.github.io/iptv/iptv.m3u",
"https://iptv-org.github.io/iptv/iptv2.m3u",
"https://iptv-org.github.io/iptv/iptv3.m3u",
)
},
)
}
}

View File

@ -0,0 +1,94 @@
package me.lsong.mytv.ui.settings.components
import android.media.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.Text
import me.lsong.mytv.utils.handleLeanbackKeyEvents
@Composable
fun LeanbackSettingsCategoryListItem(
modifier: Modifier = Modifier,
headlineContent: String,
supportingContent: String? = null,
trailingContent: @Composable () -> Unit = {},
leadingContent: @Composable (BoxScope.() -> Unit)? = null,
onSelected: (() -> Unit)? = null,
onLongSelected: () -> Unit = {},
) {
val focusRequester = remember { FocusRequester() }
var isFocused by remember { mutableStateOf(false) }
androidx.tv.material3.ListItem(
selected = false,
onClick = { },
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp),
),
leadingContent = leadingContent,
headlineContent = {
Text(text = headlineContent)
},
trailingContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
trailingContent()
}
},
supportingContent = { supportingContent?.let { Text(it) } },
modifier = modifier
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.handleLeanbackKeyEvents(
onSelect = {
if (isFocused) {
if (onSelected != null) onSelected()
} else focusRequester.requestFocus()
},
onLongSelect = {
if (isFocused) onLongSelected()
else focusRequester.requestFocus()
},
),
)
}
@Composable
fun LeanbackSettingsCategoryListItem(
modifier: Modifier = Modifier,
headlineContent: String,
supportingContent: String? = null,
trailingContent: String,
leadingContent: @Composable (BoxScope.() -> Unit)? = null,
onSelected: () -> Unit = {},
onLongSelected: () -> Unit = {},
) {
LeanbackSettingsCategoryListItem(
modifier = modifier,
leadingContent = leadingContent,
headlineContent = headlineContent,
supportingContent = supportingContent,
trailingContent = { Text(trailingContent) },
onSelected = onSelected,
onLongSelected = onLongSelected,
)
}

View File

@ -0,0 +1,77 @@
package me.lsong.mytv.ui.theme
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
private val darkColorScheme
@Composable get() = darkColorScheme(
primary = Color(0xFFA8C8FF),
onPrimary = Color(0xFF003062),
primaryContainer = Color(0xFF00468A),
onPrimaryContainer = Color(0xFFD6E3FF),
secondary = Color(0xFFBDC7DC),
onSecondary = Color(0xFF273141),
secondaryContainer = Color(0xFF3E4758),
onSecondaryContainer = Color(0xFFD9E3F8),
tertiary = Color(0xFFDCBCE1),
onTertiary = Color(0xFF3E2845),
tertiaryContainer = Color(0xFF563E5C),
onTertiaryContainer = Color(0xFFF9D8FE),
background = Color(0xFF000000),
onBackground = Color(0xFFFFFFFF),
surface = Color(0xFF1A1C1E),
onSurface = Color(0xFFE3E2E6),
surfaceVariant = Color(0xFF43474E),
onSurfaceVariant = Color(0xFFC4C6CF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFB4AB),
)
@Composable
fun LeanbackTheme(
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = darkColorScheme,
) {
androidx.tv.material3.MaterialTheme(
androidx.tv.material3.darkColorScheme(
primary = MaterialTheme.colorScheme.primary,
onPrimary = MaterialTheme.colorScheme.onPrimary,
primaryContainer = MaterialTheme.colorScheme.primaryContainer,
onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer,
secondary = MaterialTheme.colorScheme.secondary,
onSecondary = MaterialTheme.colorScheme.onSecondary,
secondaryContainer = MaterialTheme.colorScheme.secondaryContainer,
onSecondaryContainer = MaterialTheme.colorScheme.onSecondaryContainer,
tertiary = MaterialTheme.colorScheme.tertiary,
onTertiary = MaterialTheme.colorScheme.onTertiary,
tertiaryContainer = MaterialTheme.colorScheme.tertiaryContainer,
onTertiaryContainer = MaterialTheme.colorScheme.onTertiaryContainer,
background = MaterialTheme.colorScheme.background,
onBackground = MaterialTheme.colorScheme.onBackground,
surface = MaterialTheme.colorScheme.surface,
onSurface = MaterialTheme.colorScheme.onSurface,
surfaceVariant = MaterialTheme.colorScheme.surfaceVariant,
onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant,
error = MaterialTheme.colorScheme.error,
onError = MaterialTheme.colorScheme.onError,
errorContainer = MaterialTheme.colorScheme.errorContainer,
onErrorContainer = MaterialTheme.colorScheme.onErrorContainer,
),
) {
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
androidx.tv.material3.LocalContentColor provides androidx.tv.material3.MaterialTheme.colorScheme.onBackground,
) {
content()
}
}
}
}

View File

@ -0,0 +1,51 @@
package me.lsong.mytv.utils
/**
* 常量
*/
object Constants {
/**
* 应用 标题
*/
const val APP_NAME = "DuckTV"
/**
* IPTV源地址
*
*/
const val IPTV_SOURCE_URL = "http://lsong.one:8888/IPTV.m3u"
// http://lsong.one:8888/IPTV.m3u
// https://raw.githubusercontent.com/YueChan/Live/main/IPTV.m3u
// https://raw.githubusercontent.com/fanmingming/live/main/tv/m3u/ipv6.m3u
// https://live.fanmingming.com/tv/m3u/index.m3u
/**
* 节目单XML地址
*/
const val EPG_XML_URL = "http://epg.51zmt.top:8000/e.xml.gz"
// const val EPG_XML_URL = "https://live.fanmingming.com/e.xml"
/**
* HTTP请求重试次数
*/
const val HTTP_RETRY_COUNT = 10
/**
* HTTP请求重试间隔时间毫秒
*/
const val HTTP_RETRY_INTERVAL = 3000L
/**
* 播放器加载超时
*/
const val VIDEO_PLAYER_LOAD_TIMEOUT = 1000L * 15 // 15秒
/**
* 播放器 userAgent
*/
// const val VIDEO_PLAYER_USER_AGENT = "ExoPlayer"
// /**
// * 界面 临时面板界面显示时间
// */
// const val UI_TEMP_PANEL_SCREEN_SHOW_DURATION = 1500L // 1.5秒
}

View File

@ -0,0 +1,50 @@
package me.lsong.mytv.utils
import java.util.regex.Pattern
fun Long.humanizeMs(): String {
return when (this) {
in 0..<60_000 -> "${this / 1000}"
in 60_000..<3_600_000 -> "${this / 60_000}分钟"
else -> "${this / 3_600_000}小时"
}
}
fun String.isIPv6(): Boolean {
val urlPattern = Pattern.compile(
"^((http|https)://)?(\\[[0-9a-fA-F:]+])(:[0-9]+)?(/.*)?$"
)
return urlPattern.matcher(this).matches()
}
fun String.compareVersion(version2: String): Int {
fun parseVersion(version: String): Pair<List<Int>, String?> {
val mainParts = version.split("-", limit = 2)
val versionNumbers = mainParts[0].split(".").map { it.toInt() }
val preReleaseLabel = if (mainParts.size > 1) mainParts[1] else null
return versionNumbers to preReleaseLabel
}
fun comparePreRelease(label1: String?, label2: String?): Int {
if (label1 == null && label2 == null) return 0
if (label1 == null) return 1 // Non-pre-release version is greater
if (label2 == null) return -1 // Non-pre-release version is greater
// Compare pre-release labels lexicographically
return label1.compareTo(label2)
}
val (v1, preRelease1) = parseVersion(this)
val (v2, preRelease2) = parseVersion(version2)
val maxLength = maxOf(v1.size, v2.size)
for (i in 0 until maxLength) {
val part1 = v1.getOrElse(i) { 0 }
val part2 = v2.getOrElse(i) { 0 }
if (part1 > part2) return 1
if (part1 < part2) return -1
}
// If main version parts are equal, compare pre-release labels
return comparePreRelease(preRelease1, preRelease2)
}

View File

@ -0,0 +1,142 @@
package me.lsong.mytv.utils
import android.content.Context
import android.util.Log
import android.widget.Toast
import com.koushikdutta.async.AsyncServer
import com.koushikdutta.async.http.body.JSONObjectBody
import com.koushikdutta.async.http.server.AsyncHttpServer
import com.koushikdutta.async.http.server.AsyncHttpServerRequest
import com.koushikdutta.async.http.server.AsyncHttpServerResponse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.lsong.mytv.MainActivity
import me.lsong.mytv.R
import org.json.JSONObject
import java.net.Inet4Address
import java.net.NetworkInterface
import java.net.SocketException
object HttpServer {
private const val SERVER_PORT = 10481
val serverUrl: String by lazy {
"http://${getLocalIpAddress()}:$SERVER_PORT"
}
fun start(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
try {
val server = AsyncHttpServer()
server.listen(AsyncServer.getDefault(), SERVER_PORT)
server.get("/") { _, response ->
handleRawResource(response, context, "text/html", R.raw.index)
}
server.get("/api/settings") { _, response ->
handleGetSettings(response)
}
server.post("/api/settings") { request, response ->
handleSetSettings(request, response)
}
Log.i("server", "服务已启动: 0.0.0.0:$SERVER_PORT")
} catch (ex: Exception) {
Log.e("server", "服务启动失败: ${ex.message}", ex)
launch(Dispatchers.Main) {
Toast.makeText(context, "设置服务启动失败", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun wrapResponse(response: AsyncHttpServerResponse) = response.apply {
headers.set(
"Access-Control-Allow-Methods", "POST, GET, DELETE, PUT, OPTIONS"
)
headers.set("Access-Control-Allow-Origin", "*")
headers.set(
"Access-Control-Allow-Headers", "Origin, Content-Type, X-Auth-Token"
)
}
private fun handleRawResource(
response: AsyncHttpServerResponse,
context: Context,
contentType: String,
id: Int,
) {
wrapResponse(response).apply {
setContentType(contentType)
send(context.resources.openRawResource(id).readBytes().decodeToString())
}
}
private fun handleGetSettings(response: AsyncHttpServerResponse) {
wrapResponse(response).apply {
setContentType("application/json")
send(
Json.encodeToString(
AllSettings(
appTitle = Constants.APP_NAME,
epgUrls = Settings.epgUrls,
iptvSourceUrls = Settings.iptvSourceUrls,
)
)
)
}
}
private fun handleSetSettings(
request: AsyncHttpServerRequest,
response: AsyncHttpServerResponse,
) {
val body = request.getBody<JSONObjectBody>().get()
try {
val jsonObject = JSONObject(body.toString())
val epgUrls = jsonObject.getJSONArray("epgUrls").let { array ->
(0 until array.length()).map { array.getString(it) }.toSet()
}
val iptvSourceUrls = jsonObject.getJSONArray("iptvSourceUrls").let { array ->
(0 until array.length()).map { array.getString(it) }.toSet()
}
// 保存设置
Settings.epgUrls = epgUrls
Settings.iptvSourceUrls = iptvSourceUrls
wrapResponse(response).send("设置已成功保存")
} catch (e: Exception) {
wrapResponse(response).code(400).send("无效的JSON格式: ${e.message}")
}
}
private fun getLocalIpAddress(): String {
val defaultIp = "0.0.0.0"
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
val addr = iface.inetAddresses
while (addr.hasMoreElements()) {
val inetAddress = addr.nextElement()
if (!inetAddress.isLoopbackAddress && inetAddress is Inet4Address) {
return inetAddress.hostAddress ?: defaultIp
}
}
}
return defaultIp
} catch (ex: SocketException) {
Log.e("server", "IP Address: ${ex.message}", ex)
return defaultIp
}
}
}
@Serializable
private data class AllSettings(
val appTitle: String,
val epgUrls: Set<String>,
val iptvSourceUrls: Set<String>,
)

View File

@ -0,0 +1,175 @@
package me.lsong.mytv.utils
import android.os.Build
import android.view.KeyEvent
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.unit.dp
import kotlin.math.absoluteValue
fun Modifier.handleLeanbackKeyEvents(
onKeyTap: Map<Int, () -> Unit> = emptyMap(),
onKeyLongTap: Map<Int, () -> Unit> = emptyMap(),
): Modifier {
val keyDownMap = mutableMapOf<Int, Boolean>()
return onPreviewKeyEvent {
when (it.nativeKeyEvent.action) {
KeyEvent.ACTION_DOWN -> {
if (it.nativeKeyEvent.repeatCount == 0) {
keyDownMap[it.nativeKeyEvent.keyCode] = true
} else if (it.nativeKeyEvent.repeatCount == 1) {
keyDownMap.remove(it.nativeKeyEvent.keyCode)
onKeyLongTap[it.nativeKeyEvent.keyCode]?.invoke()
}
}
KeyEvent.ACTION_UP -> {
if (keyDownMap[it.nativeKeyEvent.keyCode] == true) {
keyDownMap.remove(it.nativeKeyEvent.keyCode)
onKeyTap[it.nativeKeyEvent.keyCode]?.invoke()
}
}
}
false
}
}
fun Modifier.handleLeanbackDragGestures(
onSwipeUp: () -> Unit = {},
onSwipeDown: () -> Unit = {},
onSwipeLeft: () -> Unit = {},
onSwipeRight: () -> Unit = {},
): Modifier {
val speedThreshold = 100.dp
val distanceThreshold = 10.dp
val verticalTracker = VelocityTracker()
var verticalDragOffset = 0f
val horizontalTracker = VelocityTracker()
var horizontalDragOffset = 0f
return this then pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (verticalDragOffset.absoluteValue > distanceThreshold.toPx()) {
if (verticalTracker.calculateVelocity().y > speedThreshold.toPx()) {
onSwipeDown()
} else if (verticalTracker.calculateVelocity().y < -speedThreshold.toPx()) {
onSwipeUp()
}
}
},
) { change, dragAmount ->
verticalDragOffset += dragAmount
verticalTracker.addPosition(change.uptimeMillis, change.position)
}
}.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (horizontalDragOffset.absoluteValue > distanceThreshold.toPx()) {
if (horizontalTracker.calculateVelocity().x > speedThreshold.toPx()) {
onSwipeRight()
} else if (horizontalTracker.calculateVelocity().x < -speedThreshold.toPx()) {
onSwipeLeft()
}
}
},
) { change, dragAmount ->
horizontalDragOffset += dragAmount
horizontalTracker.addPosition(change.uptimeMillis, change.position)
}
}
}
fun Modifier.handleLeanbackKeyEvents(
key: Any = Unit,
onLeft: () -> Unit = {},
onLongLeft: () -> Unit = {},
onRight: () -> Unit = {},
onLongRight: () -> Unit = {},
onUp: () -> Unit = {},
onLongUp: () -> Unit = {},
onDown: () -> Unit = {},
onLongDown: () -> Unit = {},
onSelect: () -> Unit = {},
onLongSelect: () -> Unit = {},
onSettings: () -> Unit = {},
onNumber: (Int) -> Unit = {},
) = this then handleLeanbackKeyEvents(
onKeyTap = mapOf(
KeyEvent.KEYCODE_DPAD_LEFT to onLeft,
KeyEvent.KEYCODE_DPAD_RIGHT to onRight,
KeyEvent.KEYCODE_DPAD_UP to onUp,
KeyEvent.KEYCODE_CHANNEL_UP to onUp,
KeyEvent.KEYCODE_DPAD_DOWN to onDown,
KeyEvent.KEYCODE_CHANNEL_DOWN to onDown,
KeyEvent.KEYCODE_DPAD_CENTER to onSelect,
KeyEvent.KEYCODE_ENTER to onSelect,
KeyEvent.KEYCODE_NUMPAD_ENTER to onSelect,
KeyEvent.KEYCODE_MENU to onSettings,
KeyEvent.KEYCODE_SETTINGS to onSettings,
KeyEvent.KEYCODE_HELP to onSettings,
KeyEvent.KEYCODE_H to onSettings,
KeyEvent.KEYCODE_L to onLongSelect,
KeyEvent.KEYCODE_0 to { onNumber(0) },
KeyEvent.KEYCODE_1 to { onNumber(1) },
KeyEvent.KEYCODE_2 to { onNumber(2) },
KeyEvent.KEYCODE_3 to { onNumber(3) },
KeyEvent.KEYCODE_4 to { onNumber(4) },
KeyEvent.KEYCODE_5 to { onNumber(5) },
KeyEvent.KEYCODE_6 to { onNumber(6) },
KeyEvent.KEYCODE_7 to { onNumber(7) },
KeyEvent.KEYCODE_8 to { onNumber(8) },
KeyEvent.KEYCODE_9 to { onNumber(9) },
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT to onLeft
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT to onRight
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP to onUp
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN to onDown
}
},
onKeyLongTap = mapOf(
KeyEvent.KEYCODE_DPAD_LEFT to onLongLeft,
KeyEvent.KEYCODE_DPAD_RIGHT to onLongRight,
KeyEvent.KEYCODE_DPAD_UP to onLongUp,
KeyEvent.KEYCODE_CHANNEL_UP to onLongUp,
KeyEvent.KEYCODE_DPAD_DOWN to onLongDown,
KeyEvent.KEYCODE_CHANNEL_DOWN to onLongDown,
KeyEvent.KEYCODE_ENTER to onLongSelect,
KeyEvent.KEYCODE_NUMPAD_ENTER to onLongSelect,
KeyEvent.KEYCODE_DPAD_CENTER to onLongSelect,
),
).pointerInput(key) {
detectTapGestures(
onTap = { onSelect() },
onLongPress = { onLongSelect() },
onDoubleTap = { onSettings() },
)
}
fun Modifier.handleLeanbackUserAction(onHandle: () -> Unit) =
onPreviewKeyEvent { onHandle(); false }
.pointerInput(Unit) { detectDragGestures { _, _ -> onHandle() } }
.pointerInput(Unit) {
detectTapGestures(
onTap = { onHandle() },
onDoubleTap = { onHandle() },
onLongPress = { onHandle() },
onPress = { onHandle() },
)
}

View File

@ -0,0 +1,179 @@
package me.lsong.mytv.utils
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
/**
* 应用配置存储
*/
object Settings {
private const val SP_NAME = Constants.APP_NAME
private const val SP_MODE = Context.MODE_PRIVATE
private lateinit var sp: SharedPreferences
private fun getInstance(context: Context): SharedPreferences =
context.getSharedPreferences(SP_NAME, SP_MODE)
fun init(context: Context) {
sp = getInstance(context)
}
enum class KEY {
/** ==================== 应用 ==================== */
/** 开机自启 */
APP_BOOT_LAUNCH,
/** 设备显示类型 */
APP_DEVICE_DISPLAY_TYPE,
/** ==================== 调式 ==================== */
/** 显示fps */
DEBUG_SHOW_FPS,
/** 播放器详细信息 */
DEBUG_SHOW_VIDEO_PLAYER_METADATA,
/** ==================== 直播源 ==================== */
/** 上一次直播源序号 */
IPTV_LAST_IPTV_IDX,
/** 换台反转 */
IPTV_CHANNEL_CHANGE_FLIP,
/** 直播源url */
IPTV_SOURCE_URL_LIST,
/** 直播源可播放host列表 */
IPTV_PLAYABLE_HOST_LIST,
/** 直播源历史列表 */
IPTV_SOURCE_URL_HISTORY_LIST,
/** 直播源频道收藏列表 */
IPTV_CHANNEL_FAVORITE_LIST,
/** ==================== 节目单 ==================== */
/** 节目单刷新时间阈值(小时) */
EPG_REFRESH_TIME_THRESHOLD,
/** 节目单历史列表 */
EPG_URL_LIST,
/** ==================== 界面 ==================== */
/** 界面密度缩放比例 */
UI_DENSITY_SCALE_RATIO,
/** 界面字体缩放比例 */
UI_FONT_SCALE_RATIO,
/** 时间显示模式 */
UI_TIME_SHOW_MODE,
/** 画中画模式 */
UI_PIP_MODE,
/** ==================== 播放器 ==================== */
/** 播放器 加载超时 */
VIDEO_PLAYER_LOAD_TIMEOUT,
/** 播放器 画面比例 */
VIDEO_PLAYER_ASPECT_RATIO,
}
/** ==================== 调式 ==================== */
/** 显示fps */
var debugShowFps: Boolean
get() = sp.getBoolean(KEY.DEBUG_SHOW_FPS.name, false)
set(value) = sp.edit().putBoolean(KEY.DEBUG_SHOW_FPS.name, value).apply()
/** 播放器详细信息 */
var debugShowVideoPlayerMetadata: Boolean
get() = sp.getBoolean(KEY.DEBUG_SHOW_VIDEO_PLAYER_METADATA.name, false)
set(value) = sp.edit().putBoolean(KEY.DEBUG_SHOW_VIDEO_PLAYER_METADATA.name, value).apply()
/** ==================== 直播源 ==================== */
/** 上一次直播源序号 */
var iptvLastIptvIdx: Int
get() = sp.getInt(KEY.IPTV_LAST_IPTV_IDX.name, 0)
set(value) = sp.edit().putInt(KEY.IPTV_LAST_IPTV_IDX.name, value).apply()
/** 换台反转 */
var iptvChannelChangeFlip: Boolean
get() = sp.getBoolean(KEY.IPTV_CHANNEL_CHANGE_FLIP.name, false)
set(value) = sp.edit().putBoolean(KEY.IPTV_CHANNEL_CHANGE_FLIP.name, value).apply()
/** 直播源可播放host列表 */
var iptvPlayableHostList: Set<String>
get() = sp.getStringSet(KEY.IPTV_PLAYABLE_HOST_LIST.name, emptySet()) ?: emptySet()
set(value) = sp.edit().putStringSet(KEY.IPTV_PLAYABLE_HOST_LIST.name, value).apply()
/** 直播源 url */
var iptvSourceUrls: Set<String>
get() = sp.getStringSet(KEY.IPTV_SOURCE_URL_LIST.name, emptySet()) ?: emptySet()
set(value) = sp.edit().putStringSet(KEY.IPTV_SOURCE_URL_LIST.name, value).apply()
/** 直播源历史列表 */
var iptvSourceUrlHistoryList: Set<String>
get() = sp.getStringSet(KEY.IPTV_SOURCE_URL_HISTORY_LIST.name, emptySet()) ?: emptySet()
set(value) = sp.edit().putStringSet(KEY.IPTV_SOURCE_URL_HISTORY_LIST.name, value).apply()
/** 直播源频道收藏列表 */
var iptvChannelFavoriteList: Set<String>
get() = sp.getStringSet(KEY.IPTV_CHANNEL_FAVORITE_LIST.name, emptySet()) ?: emptySet()
set(value) = sp.edit().putStringSet(KEY.IPTV_CHANNEL_FAVORITE_LIST.name, value).apply()
/** ==================== 节目单 ==================== */
/** 节目单历史列表 */
var epgUrls: Set<String>
get() = sp.getStringSet(KEY.EPG_URL_LIST.name, emptySet()) ?: emptySet()
set(value) = sp.edit().putStringSet(KEY.EPG_URL_LIST.name, value).apply()
/** ==================== 界面 ==================== */
/** 界面密度缩放比例 */
var uiDensityScaleRatio: Float
get() = sp.getFloat(KEY.UI_DENSITY_SCALE_RATIO.name, 1f)
set(value) = sp.edit().putFloat(KEY.UI_DENSITY_SCALE_RATIO.name, value).apply()
/** 界面字体缩放比例 */
var uiFontScaleRatio: Float
get() = sp.getFloat(KEY.UI_FONT_SCALE_RATIO.name, 1f)
set(value) = sp.edit().putFloat(KEY.UI_FONT_SCALE_RATIO.name, value).apply()
/** ==================== 播放器 ==================== */
/** 播放器 加载超时 */
var videoPlayerLoadTimeout: Long
get() = sp.getLong(KEY.VIDEO_PLAYER_LOAD_TIMEOUT.name, Constants.VIDEO_PLAYER_LOAD_TIMEOUT)
set(value) = sp.edit().putLong(KEY.VIDEO_PLAYER_LOAD_TIMEOUT.name, value).apply()
/** 播放器 画面比例 */
var videoPlayerAspectRatio: VideoPlayerAspectRatio
get() = VideoPlayerAspectRatio.fromValue(
sp.getInt(KEY.VIDEO_PLAYER_ASPECT_RATIO.name, VideoPlayerAspectRatio.ORIGINAL.value)
)
set(value) = sp.edit().putInt(KEY.VIDEO_PLAYER_ASPECT_RATIO.name, value.value).apply()
enum class VideoPlayerAspectRatio(val value: Int) {
/** 原始 */
ORIGINAL(0),
/** 16:9 */
ASPECT_16_9(1),
/** 4:3 */
ASPECT_4_3(2),
/** full screen */
FULL_SCREEN(3);
companion object {
fun fromValue(value: Int): VideoPlayerAspectRatio {
return entries.firstOrNull { it.value == value } ?: ORIGINAL
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DuckTV</title>
<style>
@import url("https://lsong.org/css/stylesheet.css");
@import url("https://lsong.org/stylesheets/form.css");
@import url("https://lsong.org/stylesheets/input.css");
@import url("https://lsong.org/stylesheets/button.css");
textarea.input-block {
min-width: 100%;
max-width: 100%;
}
</style>
</head>
<body>
<div class="container container-mobile">
<header>
<h1>DuckTV</h1>
</header>
<main>
<form id="settingsForm" class="form">
<h2>Settings</h2>
<div class="form-field">
<label>IPTV Source URLs</label>
<textarea class="input input-block" id="iptvSourceUrls" placeholder="每行一个URL"></textarea>
</div>
<div class="form-field">
<label>EPG URLs</label>
<textarea class="input input-block" id="epgUrls" placeholder="每行一个URL"></textarea>
</div>
<div class="form-field">
<button type="submit" class="button">保存设置</button>
</div>
</form>
</main>
</div>
<script type="module">
const ready = new Promise(done => window.addEventListener('load', done));
await ready;
const load = async () => {
const response = await fetch('/api/settings');
const settings = await response.json();
return settings;
};
const settings = await load();
document.getElementById('epgUrls').value = settings.epgUrls.join('\n');
document.getElementById('iptvSourceUrls').value = settings.iptvSourceUrls.join('\n');
const save = async (data) => {
try {
await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
alert('设置已保存');
window.location.reload();
} catch (error) {
alert('保存失败:' + error);
}
};
// 提交表单时保存设置
document.getElementById('settingsForm').addEventListener('submit', async function (e) {
e.preventDefault();
const epgUrls = document.getElementById('epgUrls').value.split('\n').filter(url => url.trim() !== '');
const iptvSourceUrls = document.getElementById('iptvSourceUrls').value.split('\n').filter(url => url.trim() !== '');
const data = {
epgUrls: epgUrls,
iptvSourceUrls: iptvSourceUrls
};
save(data);
});
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="mytv_background">#1A1C1E</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">DuckTV</string>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MyTV" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.MyTV.Leanback" parent="android:Theme.Black.NoTitleBar.Fullscreen" />
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="cache"
path="." />
</paths>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config
cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration" />
</network-security-config>

7
build.gradle.kts Normal file
View File

@ -0,0 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.kotlin.serialization) apply (false)
}

23
gradle.properties Normal file
View File

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

55
gradle/libs.versions.toml Normal file
View File

@ -0,0 +1,55 @@
[versions]
agp = "8.5.1"
coilCompose = "2.4.0"
kotlin = "2.0.0"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
kotlinxCollectionsImmutable = "0.3.7"
lifecycleRuntimeKtx = "2.8.2"
activityCompose = "1.9.0"
composeBom = "2024.06.00"
media3 = "1.3.1"
okhttp = "4.12.0"
qrose = "1.0.1"
kotlinx-serialization = "1.7.0"
androidasync = "3.1.0"
tvFoundation = "1.0.0-alpha10"
tvMaterial = "1.0.0-beta01"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
androidx-media3-exoplayer-rtsp = { group = "androidx.media3", name = "media3-exoplayer-rtsp", version.ref = "media3" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" }
androidasync = { module = "com.koushikdutta.async:androidasync", version.ref = "androidasync" }
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Thu May 09 16:25:29 CST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Normal file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

BIN
icon-x1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

BIN
logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

24
settings.gradle.kts Normal file
View File

@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "DuckTV"
include(":app")