diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..49739644 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,83 @@ +name: Bug 反馈 +description: 反馈一个 Bug +labels: [ "bug" ] +title: "[BUG] " +body: + - type: checkboxes + id: checklist + attributes: + label: 检查清单 + description: 确保我们的错误报告表单适合您。 + options: + - label: 之前没有人提交过类似或相同的 bug report。 + required: true + - label: 我正在使用本软件的最新版本。 + required: true + - type: dropdown + id: version + attributes: + label: my-ty 版本 + description: 请选择正在使用的版本 + options: + - 最新稳定版 + - 最新 CI 版 + validations: + required: true + - type: textarea + id: bug + attributes: + label: Bug 描述 + description: 请描述 bug 详情 + placeholder: | + e.g. Crashed when generating snapshot. + validations: + required: true + - type: textarea + id: expected + attributes: + label: 预期行为 + description: 你预期会发生什么? + placeholder: | + e.g. A New snapshot! + validations: + required: true + - type: textarea + id: actual + attributes: + label: 实际行为 + description: 反而发生了什么? + placeholder: | + e.g. Crashed. + validations: + required: true + - type: textarea + id: steps + attributes: + label: 复现步骤 + description: 如何复现这个 bug。 + placeholder: | + 1. Open the app + 2. Crashed + + What an app. + - type: input + id: ui + attributes: + label: UI / OS + description: 你的电视系统 UI 或 OS 或 品牌 + placeholder: TCL / XIAOMI / PHONE / etc. + validations: + required: true + - type: input + id: android + attributes: + label: Android 版本 + description: 你的 Android 版本 + placeholder: "12" + validations: + required: true + - type: textarea + id: additional + attributes: + label: 额外信息 + description: 任何你觉得值得说的。 diff --git a/.github/ISSUE_TEMPLATE/fr.yml b/.github/ISSUE_TEMPLATE/fr.yml new file mode 100644 index 00000000..e409ba49 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/fr.yml @@ -0,0 +1,36 @@ +name: 功能(新频道)请求 +description: 提出一个建议 +labels: [ "enhancement" ] +title: "[FR] " +body: + - type: checkboxes + id: checklist + attributes: + label: 检查清单 + description: 确保我们的错误报告表单适合您。 + options: + - label: 之前没有人提交过类似或相同的功能请求。 + required: true + - label: 这个建议不会背离 LibChecker 的初衷。 + required: true + - type: textarea + id: propose + attributes: + label: 改进目的 + description: 改进有什么用 + placeholder: | + Show your idea here. + validations: + required: true + - type: textarea + id: solution + attributes: + label: 解决方案 + description: 你会怎么完成这个改进? + placeholder: | + How to do it on your opinion? Or left this blank + - type: textarea + id: addition + attributes: + label: 额外信息 + description: 任何你觉得值得说的。 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..71beb31b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,59 @@ +name: build + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Run build with Gradle wrapper + run: ./gradlew assembleRelease -PIS_SO_BUILD=false + + - name: Sign app APK + id: sign_app + uses: r0adkll/sign-android-release@v1 + with: + releaseDirectory: app/build/outputs/apk/release + alias: ${{ secrets.ALIAS }} + signingKeyBase64: ${{ secrets.KEYSTORE }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + keyPassword: ${{ secrets.ALIAS_PASSWORD }} + env: + # override default build-tools version (29.0.3) -- optional + BUILD_TOOLS_VERSION: "34.0.0" + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ${{ steps.sign_app.outputs.signedReleaseFile }} + asset_name: my-tv-${{ github.ref_name }}.apk + asset_content_type: application/vnd.android.package-archive \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..565a5412 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +.idea +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/README.md b/README.md new file mode 100644 index 00000000..ff59e088 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# 我的电视·〇 + +电视播放软件,可以自定义源 + +## 使用 + +下载安装 [releases](https://github.com/lizongying/my-tv-0/releases/) + +更多地址 [my-tv](https://lyrics.run/my-tv-0.html) + +![image](./screenshots/img.png) + +## 更新日志 + +### v1.0.0 + +* 基本视频播放 + +## 其他 + +小米电视可以使用小米电视助手进行安装 + +如电视可以启用ADB,也可以通过ADB进行安装: + +```shell +adb install my-tv-0.apk +``` + +## TODO + +* 音量不同 +* 收藏夹 +* 自定义源 +* 节目增加预告 +* 频道列表优化 + +## 赞赏 + +![image](./screenshots/appreciate.jpeg) \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..9f75202a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.lizongying.mytv0" + compileSdk = 34 + + defaultConfig { + applicationId = "com.lizongying.mytv0" + minSdk = 23 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + + } + + buildFeatures { + viewBinding = true + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + val media3Version = "1.3.0-rc01" + implementation("androidx.media3:media3-ui:$media3Version") + + // For media playback using ExoPlayer + implementation("androidx.media3:media3-exoplayer:$media3Version") + + // For HLS playback support with ExoPlayer + implementation("androidx.media3:media3-exoplayer-hls:$media3Version") + + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.leanback:leanback:1.0.0") + implementation("com.github.bumptech.glide:glide:4.11.0") + + // 21:2.9.0 17:2.6.4 + val retrofit2Version = "2.6.4" + implementation("com.squareup.retrofit2:converter-gson:$retrofit2Version") + implementation ("com.squareup.retrofit2:converter-protobuf:$retrofit2Version") + implementation ("com.squareup.retrofit2:retrofit:$retrofit2Version") + + // For yunos + val exoplayerVersion = "2.13.3" + implementation("com.google.android.exoplayer:exoplayer-ui:$exoplayerVersion") + implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion") + implementation("com.google.android.exoplayer:exoplayer-hls:$exoplayerVersion") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0-RC") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/release/app-release.apk b/app/release/app-release.apk new file mode 100644 index 00000000..4a5e1303 Binary files /dev/null and b/app/release/app-release.apk differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 00000000..856af11e --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.lizongying.mytv0", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..df25072d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/CategoryAdapter.kt b/app/src/main/java/com/lizongying/mytv0/CategoryAdapter.kt new file mode 100644 index 00000000..253e5501 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/CategoryAdapter.kt @@ -0,0 +1,150 @@ +package com.lizongying.mytv0 + +import android.content.Context +import android.util.Log +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.lizongying.mytv0.databinding.CategoryItemBinding +import com.lizongying.mytv0.models.TVCategoryModel +import com.lizongying.mytv0.models.TVListModel + + +class CategoryAdapter( + private val context: Context, + private val recyclerView: RecyclerView, + private var tvCategoryModel: TVCategoryModel, +) : + RecyclerView.Adapter() { + + private var listener: ItemListener? = null + private var focused: View? = null + private var defaultFocused = false + var defaultFocus: Int = -1 + + var visiable = false + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(context) + val binding = CategoryItemBinding.inflate(inflater, parent, false) + binding.root.isFocusable = true + binding.root.isFocusableInTouchMode = true + return ViewHolder(context, binding) + } + + fun focusable(able: Boolean) { + recyclerView.isFocusable = able + recyclerView.isFocusableInTouchMode = able + if (able) { + recyclerView.descendantFocusability = ViewGroup.FOCUS_BEFORE_DESCENDANTS + } else { + recyclerView.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + } + } + + fun clear() { + focused?.clearFocus() + recyclerView.invalidate() + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val tvListModel = tvCategoryModel.getTVListModel(position)!! + val view = viewHolder.itemView + view.tag = position + + if (!defaultFocused && position == defaultFocus) { + view.requestFocus() + defaultFocused = true + } + + val onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + listener?.onItemFocusChange(tvListModel, hasFocus) + + if (hasFocus) { + viewHolder.focus(true) + focused = view + if (visiable) { + if (position != tvCategoryModel.position.value) { + tvCategoryModel.setPosition(position) + } + } else { + visiable = true + } + } else { + viewHolder.focus(false) + } + } + + view.onFocusChangeListener = onFocusChangeListener + + view.setOnClickListener { _ -> + listener?.onItemClicked(tvListModel) + } + + view.setOnKeyListener { v, keyCode, event: KeyEvent? -> + if (event?.action == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_DPAD_UP && position == 0) { + recyclerView.smoothScrollToPosition(getItemCount() - 1) + } + + if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && position == getItemCount() - 1) { + recyclerView.smoothScrollToPosition(0) + } + + return@setOnKeyListener listener?.onKey(keyCode) ?: false + } + false + } + + viewHolder.bind(tvListModel.getName()) + } + + override fun getItemCount() = tvCategoryModel.size() + + class ViewHolder(private val context: Context, private val binding: CategoryItemBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(text: String) { + binding.textView.text = text + } + + fun focus(hasFocus: Boolean) { + if (hasFocus) { + binding.textView.setTextColor(ContextCompat.getColor(context, R.color.white)) + } else { + binding.textView.setTextColor( + ContextCompat.getColor( + context, + R.color.description_blur + ) + ) + } + } + } + + fun toPosition(position: Int) { + recyclerView.post { + Log.i(TAG, "category smoothScrollToPosition $position") + recyclerView.scrollToPosition(position) + recyclerView.getChildAt(position)?.isSelected + recyclerView.getChildAt(position)?.requestFocus() + } + } + + interface ItemListener { + fun onItemFocusChange(tvListModel: TVListModel, hasFocus: Boolean) + fun onItemClicked(tvListModel: TVListModel) + fun onKey(keyCode: Int): Boolean + } + + fun setItemListener(listener: ItemListener) { + this.listener = listener + } + + companion object { + private const val TAG = "CategoryAdapter" + } +} + diff --git a/app/src/main/java/com/lizongying/mytv0/ChannelFragment.kt b/app/src/main/java/com/lizongying/mytv0/ChannelFragment.kt new file mode 100644 index 00000000..33ef706e --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/ChannelFragment.kt @@ -0,0 +1,82 @@ +package com.lizongying.mytv0 + +import android.os.Bundle +import android.os.Handler +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.lizongying.mytv0.databinding.ChannelBinding +import com.lizongying.mytv0.models.TVModel + +class ChannelFragment : Fragment() { + private var _binding: ChannelBinding? = null + private val binding get() = _binding!! + + private val handler = Handler() + private val delay: Long = 3000 + private var channel = 0 + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = ChannelBinding.inflate(inflater, container, false) + _binding!!.root.visibility = View.GONE + return binding.root + } + + fun show(tvViewModel: TVModel) { + handler.removeCallbacks(hideRunnable) + handler.removeCallbacks(playRunnable) + binding.channelContent.text = (tvViewModel.tv.id.plus(1)).toString() + view?.visibility = View.VISIBLE + handler.postDelayed(hideRunnable, delay) + } + + fun show(channel: String) { + this.channel = "${binding.channelContent.text}$channel".toInt() + handler.removeCallbacks(hideRunnable) + handler.removeCallbacks(playRunnable) + if (binding.channelContent.text == "") { + binding.channelContent.text = channel + view?.visibility = View.VISIBLE + handler.postDelayed(playRunnable, delay) + } else { + handler.postDelayed(playRunnable, 0) + } + } + + override fun onResume() { + super.onResume() + if (view?.visibility == View.VISIBLE) { + handler.postDelayed(hideRunnable, delay) + } + } + + override fun onPause() { + super.onPause() + handler.removeCallbacks(hideRunnable) + handler.removeCallbacks(playRunnable) + } + + private val hideRunnable = Runnable { + binding.channelContent.text = "" + view?.visibility = View.GONE + } + + private val playRunnable = Runnable { + (activity as MainActivity).play(channel - 1) + binding.channelContent.text = "" + view?.visibility = View.GONE + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val TAG = "ChannelFragment" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/IgnoreSSLCertificate.kt b/app/src/main/java/com/lizongying/mytv0/IgnoreSSLCertificate.kt new file mode 100644 index 00000000..1522e2e1 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/IgnoreSSLCertificate.kt @@ -0,0 +1,48 @@ +package com.lizongying.mytv0 + +import java.security.cert.X509Certificate +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +object IgnoreSSLCertificate { + + fun ignore() { + try { + val trustAllCerts: Array = arrayOf( + object : X509TrustManager { + override fun getAcceptedIssuers(): Array? { + return null + } + + override fun checkClientTrusted( + certs: Array?, + authType: String? + ) { + } + + override fun checkServerTrusted( + certs: Array?, + authType: String? + ) { + } + } + ) + + // Install the all-trusting trust manager + val sc = SSLContext.getInstance("SSL") + sc.init(null, trustAllCerts, java.security.SecureRandom()) + HttpsURLConnection.setDefaultSSLSocketFactory(sc.socketFactory) + + // Create all-trusting host name verifier + val allHostsValid = HostnameVerifier { _, _ -> true } + + // Install the all-trusting host verifier + HttpsURLConnection.setDefaultHostnameVerifier(allHostsValid) + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/com/lizongying/mytv0/InfoFragment.kt b/app/src/main/java/com/lizongying/mytv0/InfoFragment.kt new file mode 100644 index 00000000..77deb4bc --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/InfoFragment.kt @@ -0,0 +1,75 @@ +package com.lizongying.mytv0 + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.os.Handler +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.SimpleTarget +import com.bumptech.glide.request.transition.Transition +import com.lizongying.mytv0.databinding.InfoBinding +import com.lizongying.mytv0.models.TVModel + + +class InfoFragment : Fragment() { + private var _binding: InfoBinding? = null + private val binding get() = _binding!! + + private val handler = Handler() + private val delay: Long = 3000 + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = InfoBinding.inflate(inflater, container, false) + _binding!!.root.visibility = View.GONE + return binding.root + } + + fun show(tvViewModel: TVModel) { + binding.textView.text = tvViewModel.tv.title + + when (tvViewModel.tv.title) { + else -> Glide.with(this) + .load(tvViewModel.tv.logo) + .into(binding.infoLogo) + } + +// val program = tvViewModel.getProgramOne() +// if (program != null) { +// binding.infoDesc.text = program.name +// } + + handler.removeCallbacks(removeRunnable) + view?.visibility = View.VISIBLE + handler.postDelayed(removeRunnable, delay) + } + + override fun onResume() { + super.onResume() + handler.postDelayed(removeRunnable, delay) + } + + override fun onPause() { + super.onPause() + handler.removeCallbacks(removeRunnable) + } + + private val removeRunnable = Runnable { + view?.visibility = View.GONE + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val TAG = "InfoFragment" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/InitializerProvider.kt b/app/src/main/java/com/lizongying/mytv0/InitializerProvider.kt new file mode 100644 index 00000000..9ec0ff16 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/InitializerProvider.kt @@ -0,0 +1,41 @@ +package com.lizongying.mytv0 + +import android.content.ContentProvider +import android.content.ContentValues +import android.net.Uri +import com.lizongying.mytv0.models.TVList + +internal class InitializerProvider : ContentProvider() { + + // Happens before Application#onCreate.It's fine to init something here + override fun onCreate(): Boolean { + SP.init(context!!) + TVList.init(context!!) + return true + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ) = unsupported() + + override fun getType(uri: Uri) = unsupported() + + override fun insert(uri: Uri, values: ContentValues?) = unsupported() + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = + unsupported() + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ) = unsupported() + + private fun unsupported(errorMessage: String? = null): Nothing = + throw UnsupportedOperationException(errorMessage) +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/ListAdapter.kt b/app/src/main/java/com/lizongying/mytv0/ListAdapter.kt new file mode 100644 index 00000000..5c704129 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/ListAdapter.kt @@ -0,0 +1,173 @@ +package com.lizongying.mytv0 + +import android.content.Context +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.FOCUS_BEFORE_DESCENDANTS +import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS +import android.view.animation.ScaleAnimation +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.lizongying.mytv0.databinding.ListItemBinding +import com.lizongying.mytv0.models.TVListModel +import com.lizongying.mytv0.models.TVModel + + +class ListAdapter( + private val context: Context, + private val recyclerView: RecyclerView, + var tvListModel: TVListModel, +) : + RecyclerView.Adapter() { + private var listener: ItemListener? = null + private var focused: View? = null + private var defaultFocused = false + var defaultFocus: Int = -1 + + var visiable = false + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(context) + val binding = ListItemBinding.inflate(inflater, parent, false) + return ViewHolder(context, binding) + } + + fun focusable(able: Boolean) { + recyclerView.isFocusable = able + recyclerView.isFocusableInTouchMode = able + if (able) { + recyclerView.descendantFocusability = FOCUS_BEFORE_DESCENDANTS + } else { + recyclerView.descendantFocusability = FOCUS_BLOCK_DESCENDANTS + } + } + + fun update(tvListModel: TVListModel) { + this.tvListModel = tvListModel + recyclerView.post { + notifyDataSetChanged() + } + } + + fun clear() { + focused?.clearFocus() + recyclerView.invalidate() + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val tvModel = tvListModel.getTVModel(position)!! + val view = viewHolder.itemView + + view.isFocusable = true + view.isFocusableInTouchMode = true + view.alpha = 0.8F + + if (!defaultFocused && position == defaultFocus) { + view.requestFocus() + defaultFocused = true + } + + val onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + listener?.onItemFocusChange(tvModel, hasFocus) + + if (hasFocus) { + viewHolder.focus(true) + focused = view + if (visiable) { + if (position != tvListModel.position.value) { + tvListModel.setPosition(position) + } + } else { + visiable = true + } + } else { + viewHolder.focus(false) + } + } + + view.onFocusChangeListener = onFocusChangeListener + + view.setOnClickListener { _ -> + listener?.onItemClicked(tvModel) + } + + view.setOnKeyListener { _, keyCode, event: KeyEvent? -> + if (event?.action == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_DPAD_UP && position == 0) { + recyclerView.smoothScrollToPosition(getItemCount() - 1) + } + + if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && position == getItemCount() - 1) { + recyclerView.smoothScrollToPosition(0) + } + + return@setOnKeyListener listener?.onKey(this, keyCode) ?: false + } + false + } + + viewHolder.bindText(tvModel.tv.title) + + viewHolder.bindImage(tvModel.tv.logo) + } + + override fun getItemCount() = tvListModel.size() + + class ViewHolder(private val context: Context, private val binding: ListItemBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bindText(text: String) { + binding.textView.text = text + } + + fun bindImage(url: String) { + Glide.with(context) + .load(url) + .centerInside() + .into(binding.imageView) + } + + fun focus(hasFocus: Boolean) { + if (hasFocus) { + binding.textView.setTextColor(ContextCompat.getColor(context, R.color.white)) + binding.description.setTextColor(ContextCompat.getColor(context, R.color.white)) + binding.root.alpha = 1.0F + } else { + binding.textView.setTextColor(ContextCompat.getColor(context, R.color.title_blur)) + binding.description.setTextColor( + ContextCompat.getColor( + context, + R.color.description_blur + ) + ) + binding.root.alpha = 0.8F + } + } + } + + fun toPosition(position: Int) { + recyclerView.post { + recyclerView.scrollToPosition(position) + recyclerView.getChildAt(position)?.isSelected + recyclerView.getChildAt(position)?.requestFocus() + } + } + + interface ItemListener { + fun onItemFocusChange(tvModel: TVModel, hasFocus: Boolean) + fun onItemClicked(tvModel: TVModel) + fun onKey(listAdapter: ListAdapter, keyCode: Int): Boolean + } + + fun setItemListener(listener: ItemListener) { + this.listener = listener + } + + companion object { + private const val TAG = "ListAdapter" + } +} + diff --git a/app/src/main/java/com/lizongying/mytv0/MainActivity.kt b/app/src/main/java/com/lizongying/mytv0/MainActivity.kt new file mode 100644 index 00000000..d5b7f6c2 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/MainActivity.kt @@ -0,0 +1,382 @@ +package com.lizongying.mytv0 + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.KeyEvent +import android.view.View +import android.view.WindowManager +import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import com.lizongying.mytv0.models.TVList + + +class MainActivity : FragmentActivity() { + + private var playerFragment = PlayerFragment() + private var infoFragment = InfoFragment() + private var channelFragment = ChannelFragment() + private var menuFragment = MenuFragment() + private lateinit var settingFragment: SettingFragment + + private val handler = Handler(Looper.myLooper()!!) + private val delayHideMenu = 10000L + private val delayHideSetting = 10000L + + private var position = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .add(R.id.main_browse_fragment, playerFragment) + .add(R.id.main_browse_fragment, infoFragment) + .add(R.id.main_browse_fragment, channelFragment) + .add(R.id.main_browse_fragment, menuFragment) + .hide(menuFragment) + .commitNow() + } + + TVList.listModel.forEach { tvModel -> + tvModel.errInfo.observe(this) { _ -> + if (tvModel.errInfo.value != null + && tvModel.tv.id == TVList.position.value + ) { + Toast.makeText(this, tvModel.errInfo.value, Toast.LENGTH_LONG) + .show() + } + } + tvModel.ready.observe(this) { _ -> + + // not first time && channel is not changed + if (tvModel.ready.value != null + && tvModel.tv.id == TVList.position.value + ) { + Log.i(TAG, "info ${tvModel.tv.title}") + infoFragment.show(tvModel) + if (SP.channelNum) { + channelFragment.show(tvModel) + } + } + } + } + + TVList.setPosition(SP.position) + + val packageInfo = getPackageInfo() + val versionName = packageInfo.versionName + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + packageInfo.versionCode.toLong() + } + settingFragment = SettingFragment(versionName, versionCode) + } + + fun play(position: Int) { + if (position > -1 && position < TVList.size()) { + TVList.setPosition(position) + } else { + Toast.makeText(this, "频道不存在", Toast.LENGTH_LONG).show() + } + } + + fun prev() { + position = TVList.position.value?.dec() ?: 0 + if (position == -1) { + position = TVList.size() - 1 + } + TVList.setPosition(position) + } + + fun next() { + position = TVList.position.value?.inc() ?: 0 + if (position == TVList.size()) { + position = 0 + } + TVList.setPosition(position) + } + + fun menuActive() { + handler.removeCallbacks(hideMenu) + handler.postDelayed(hideMenu, delayHideMenu) + } + + val hideMenu = Runnable { + if (!menuFragment.isHidden) { + supportFragmentManager.beginTransaction().hide(menuFragment).commit() + } + } + + fun settingActive() { + handler.removeCallbacks(hideSetting) + handler.postDelayed(hideSetting, delayHideSetting) + } + + private val hideSetting = Runnable { + if (!settingFragment.isHidden) { + supportFragmentManager.beginTransaction().hide(settingFragment).commit() + } + } + + private fun getPackageInfo(): PackageInfo { + val flag = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + PackageManager.GET_SIGNATURES + } else { + PackageManager.GET_SIGNING_CERTIFICATES + } + + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, flag) + } else { + packageManager.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()) + ) + } + } + + private fun showChannel(channel: String) { + if (!menuFragment.isHidden) { + return + } + + if (settingFragment.isVisible) { + return + } + + if (SP.channelNum) { + channelFragment.show(channel) + } + } + + + private fun channelUp() { + if (menuFragment.isHidden) { + if (SP.channelReversal) { + next() + return + } + prev() + } + } + + private fun channelDown() { + if (menuFragment.isHidden) { + if (SP.channelReversal) { + prev() + return + } + next() + } + } + + private fun back() { + if (!menuFragment.isHidden) { + hideMenuFragment() + return + } +// +// if (doubleBackToExitPressedOnce) { +// super.onBackPressed() +// return +// } +// +// doubleBackToExitPressedOnce = true +// Toast.makeText(this, "再按一次退出", Toast.LENGTH_SHORT).show() +// +// Handler(Looper.getMainLooper()).postDelayed({ +// doubleBackToExitPressedOnce = false +// }, 2000) + } + + + private fun showSetting() { + if (!menuFragment.isHidden) { + return + } + + +// Log.i(TAG, "settingFragment ${settingFragment.isVisible}") +// if (!settingFragment.isVisible) { +// settingFragment.show(supportFragmentManager, "setting") +// settingActive() +// } else { +// handler.removeCallbacks(hideSetting) +// settingFragment.dismiss() +// } + } + + fun switchMainFragment() { + val transaction = supportFragmentManager.beginTransaction() + + if (menuFragment.isHidden) { +// menuFragment.setPosition() + transaction.show(menuFragment) + menuActive() + } else { + transaction.hide(menuFragment) + } + + transaction.commit() + } + + + private fun showMenuFragment() { + supportFragmentManager.beginTransaction() + .show(menuFragment) + .commitNow() + menuActive() + } + + fun hideMenuFragment() { + supportFragmentManager.beginTransaction() + .hide(menuFragment) + .commit() + } + + fun onKey(keyCode: Int): Boolean { + Log.d(TAG, "keyCode $keyCode") + when (keyCode) { + KeyEvent.KEYCODE_0 -> { + showChannel("0") + return true + } + + KeyEvent.KEYCODE_1 -> { + showChannel("1") + return true + } + + KeyEvent.KEYCODE_2 -> { + showChannel("2") + return true + } + + KeyEvent.KEYCODE_3 -> { + showChannel("3") + return true + } + + KeyEvent.KEYCODE_4 -> { + showChannel("4") + return true + } + + KeyEvent.KEYCODE_5 -> { + showChannel("5") + return true + } + + KeyEvent.KEYCODE_6 -> { + showChannel("6") + return true + } + + KeyEvent.KEYCODE_7 -> { + showChannel("7") + return true + } + + KeyEvent.KEYCODE_8 -> { + showChannel("8") + return true + } + + KeyEvent.KEYCODE_9 -> { + showChannel("9") + return true + } + + KeyEvent.KEYCODE_ESCAPE -> { + back() + return true + } + + KeyEvent.KEYCODE_BACK -> { + back() + return true + } + + KeyEvent.KEYCODE_BOOKMARK -> { + showSetting() + return true + } + + KeyEvent.KEYCODE_UNKNOWN -> { + showSetting() + return true + } + + KeyEvent.KEYCODE_HELP -> { + showSetting() + return true + } + + KeyEvent.KEYCODE_SETTINGS -> { + showSetting() + return true + } + + KeyEvent.KEYCODE_MENU -> { + showSetting() + return true + } + + KeyEvent.KEYCODE_ENTER -> { + switchMainFragment() + } + + KeyEvent.KEYCODE_DPAD_CENTER -> { + switchMainFragment() + } + + KeyEvent.KEYCODE_DPAD_UP -> { + channelUp() + } + + KeyEvent.KEYCODE_CHANNEL_UP -> { + channelUp() + } + + KeyEvent.KEYCODE_DPAD_DOWN -> { + channelDown() + } + + KeyEvent.KEYCODE_CHANNEL_DOWN -> { + channelDown() + } + + KeyEvent.KEYCODE_DPAD_LEFT -> { + showMenuFragment() + } + + KeyEvent.KEYCODE_DPAD_RIGHT -> { + channelDown() + } + } + return false + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (onKey(keyCode)) { + return true + } + + return super.onKeyDown(keyCode, event) + } + + companion object { + private const val TAG = "MainActivity" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/MenuFragment.kt b/app/src/main/java/com/lizongying/mytv0/MenuFragment.kt new file mode 100644 index 00000000..37b85aa0 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/MenuFragment.kt @@ -0,0 +1,160 @@ +package com.lizongying.mytv0 + +import android.os.Bundle +import android.util.Log +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import com.lizongying.mytv0.databinding.MenuBinding +import com.lizongying.mytv0.models.TVList +import com.lizongying.mytv0.models.TVListModel +import com.lizongying.mytv0.models.TVModel + +class MenuFragment : Fragment(), CategoryAdapter.ItemListener, ListAdapter.ItemListener { + private var _binding: MenuBinding? = null + private val binding get() = _binding!! + + private lateinit var categoryAdapter: CategoryAdapter + private lateinit var listAdapter: ListAdapter + + override fun onActivityCreated(savedInstanceState: Bundle?) { + Log.i(TAG, "onCreate") + super.onActivityCreated(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = MenuBinding.inflate(inflater, container, false) + + categoryAdapter = CategoryAdapter( + context!!, + binding.category, + TVList.categoryModel, + ) + binding.category.adapter = categoryAdapter + binding.category.layoutManager = + LinearLayoutManager(context) + categoryAdapter.setItemListener(this) + + listAdapter = ListAdapter( + context!!, + binding.list, + TVList.categoryModel.getTVListModel(TVList.categoryModel.position.value!!)!!, + ) + binding.list.adapter = listAdapter + binding.list.layoutManager = + LinearLayoutManager(context) + listAdapter.focusable(false) + listAdapter.setItemListener(this) + return binding.root + } + + override fun onItemFocusChange(tvListModel: TVListModel, hasFocus: Boolean) { + if (hasFocus) { + (binding.list.adapter as ListAdapter).update(tvListModel) + (activity as MainActivity).menuActive() + } + } + + override fun onItemClicked(tvListModel: TVListModel) { + } + + override fun onItemFocusChange(tvModel: TVModel, hasFocus: Boolean) { + if (hasFocus) { + (activity as MainActivity).menuActive() + } + } + + override fun onItemClicked(tvModel: TVModel) { + TVList.setPosition(tvModel.tv.id) + (activity as MainActivity).hideMenuFragment() + } + + override fun onKey(keyCode: Int): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (listAdapter.itemCount == 0) { + Toast.makeText(context, "暂无频道", Toast.LENGTH_LONG).show() + return true + } + binding.category.visibility = GONE + categoryAdapter.focusable(false) + listAdapter.focusable(true) + listAdapter.toPosition(listAdapter.tvListModel.position.value!!) + return true + } + + KeyEvent.KEYCODE_DPAD_LEFT -> { + (activity as MainActivity).hideMenuFragment() + return true + } + } + return false + } + + override fun onKey(listAdapter: ListAdapter, keyCode: Int): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { + binding.category.visibility = VISIBLE + categoryAdapter.focusable(true) + listAdapter.focusable(false) + listAdapter.clear() + Log.i(TAG, "category toPosition on left") + categoryAdapter.toPosition(TVList.categoryModel.position.value!!) + return true + } + } + return false + } + + override fun onHiddenChanged(hidden: Boolean) { + super.onHiddenChanged(hidden) + if (!hidden) { + if (binding.list.isVisible) { +// if (binding.category.isVisible) { +// categoryAdapter.focusable(true) +// listAdapter.focusable(false) +// } else { +// categoryAdapter.focusable(false) +// listAdapter.focusable(true) +// } + Log.i(TAG, "list on show toPosition ${listAdapter.tvListModel.position.value!!}") + listAdapter.toPosition(listAdapter.tvListModel.position.value!!) + } + if (binding.category.isVisible) { +// categoryAdapter.focusable(true) +// listAdapter.focusable(false) + Log.i(TAG, "category on show toPosition ${TVList.categoryModel.position.value!!}") + categoryAdapter.toPosition(TVList.categoryModel.position.value!!) + } + } else { + view?.post { + categoryAdapter.visiable = false + listAdapter.visiable = false + } + } + } + + override fun onResume() { + super.onResume() +// categoryAdapter.toPosition(TVList.categoryModel.position.value!!) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val TAG = "MenuFragment" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/PlayerFragment.kt b/app/src/main/java/com/lizongying/mytv0/PlayerFragment.kt new file mode 100644 index 00000000..e98d9c85 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/PlayerFragment.kt @@ -0,0 +1,311 @@ +package com.lizongying.mytv0 + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import androidx.annotation.OptIn +import androidx.fragment.app.Fragment +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.TransferListener +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.mediacodec.MediaCodecUtil +import com.google.android.exoplayer2.SimpleExoPlayer +import com.lizongying.mytv0.databinding.PlayerBinding +import com.lizongying.mytv0.models.TVList +import com.lizongying.mytv0.models.TVModel + + +class PlayerFragment : Fragment(), SurfaceHolder.Callback { + private var _binding: PlayerBinding? = null + + private var player: ExoPlayer? = null + private var exoPlayer: SimpleExoPlayer? = null + + private var videoUrl = "" + private var tvModel: TVModel? = null + private val aspectRatio = 16f / 9f + + private lateinit var surfaceView: SurfaceView + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = PlayerBinding.inflate(inflater, container, false) + val playerView = _binding!!.playerView + surfaceView = _binding!!.surfaceView + + if (Utils.isTmallDevice()) { + playerView.visibility = View.GONE + surfaceView.holder.addCallback(this) + } else { + surfaceView.visibility = View.GONE + playerView.viewTreeObserver?.addOnGlobalLayoutListener(object : + ViewTreeObserver.OnGlobalLayoutListener { + @OptIn(UnstableApi::class) + override fun onGlobalLayout() { + playerView.viewTreeObserver.removeOnGlobalLayoutListener(this) + + val renderersFactory = context?.let { DefaultRenderersFactory(it) } + val playerMediaCodecSelector = PlayerMediaCodecSelector() + renderersFactory?.setMediaCodecSelector(playerMediaCodecSelector) + + player = context?.let { + ExoPlayer.Builder(it) + .setRenderersFactory(renderersFactory!!) + .build() + } + playerView.player = player + player?.playWhenReady = true + player?.addListener(object : Player.Listener { + override fun onVideoSizeChanged(videoSize: VideoSize) { + val ratio = playerView.measuredWidth.div(playerView.measuredHeight) + val layoutParams = playerView.layoutParams + if (ratio < aspectRatio) { + layoutParams?.height = + (playerView.measuredWidth.div(aspectRatio)).toInt() + playerView.layoutParams = layoutParams + } else if (ratio > aspectRatio) { + layoutParams?.width = + (playerView.measuredHeight.times(aspectRatio)).toInt() + playerView.layoutParams = layoutParams + } + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + tvModel?.setReady() + } + }) + Log.i(TAG, "player ready") + ready() + } + }) + } + + return _binding!!.root + } + + fun ready() { + TVList.listModel.forEach { tvModel -> + tvModel.ready.observe(this) { _ -> + + // not first time + if (tvModel.ready.value != null + && tvModel.tv.id == TVList.position.value + && tvModel.videoUrl.value != null +// && tvModel.videoUrl.value != videoUrl + ) { + play(tvModel) + } + } + } + } + + @OptIn(UnstableApi::class) + fun play(tvModel: TVModel) { + videoUrl = tvModel.videoUrl.value ?: return + this.tvModel = tvModel + Log.i(TAG, "play ${tvModel.tv.title} $videoUrl") + player?.run { + IgnoreSSLCertificate.ignore() + val httpDataSource = DefaultHttpDataSource.Factory() + httpDataSource.setTransferListener(object : TransferListener { + override fun onTransferInitializing( + source: DataSource, + dataSpec: DataSpec, + isNetwork: Boolean + ) { +// TODO("Not yet implemented") + } + + override fun onTransferStart( + source: DataSource, + dataSpec: DataSpec, + isNetwork: Boolean + ) { + Log.d(TAG, "onTransferStart uri ${source.uri}") +// TODO("Not yet implemented") + } + + override fun onBytesTransferred( + source: DataSource, + dataSpec: DataSpec, + isNetwork: Boolean, + bytesTransferred: Int + ) { +// TODO("Not yet implemented") + } + + override fun onTransferEnd( + source: DataSource, + dataSpec: DataSpec, + isNetwork: Boolean + ) { +// TODO("Not yet implemented") + } + }) + + val hlsMediaSource = HlsMediaSource.Factory(httpDataSource).createMediaSource( + MediaItem.fromUri(videoUrl) + ) + + setMediaSource(hlsMediaSource) + +// setMediaItem(MediaItem.fromUri(videoUrl)) + prepare() + } + exoPlayer?.run { + setMediaItem(com.google.android.exoplayer2.MediaItem.fromUri(videoUrl)) + prepare() + } + } + + @OptIn(UnstableApi::class) + class PlayerMediaCodecSelector : MediaCodecSelector { + override fun getDecoderInfos( + mimeType: String, + requiresSecureDecoder: Boolean, + requiresTunnelingDecoder: Boolean + ): MutableList { + val infos = MediaCodecUtil.getDecoderInfos( + mimeType, + requiresSecureDecoder, + requiresTunnelingDecoder + ) + if (mimeType == MimeTypes.VIDEO_H265 && !requiresSecureDecoder && !requiresTunnelingDecoder) { + if (infos.size > 0) { + val infosNew = infos.find { it.name == "c2.android.hevc.decoder" } + ?.let { mutableListOf(it) } + if (infosNew != null) { + return infosNew + } + } + } + return infos + } + } + + class ExoplayerMediaCodecSelector : + com.google.android.exoplayer2.mediacodec.MediaCodecSelector { + override fun getDecoderInfos( + mimeType: String, + requiresSecureDecoder: Boolean, + requiresTunnelingDecoder: Boolean + ): MutableList { + val infos = com.google.android.exoplayer2.mediacodec.MediaCodecUtil.getDecoderInfos( + mimeType, + requiresSecureDecoder, + requiresTunnelingDecoder + ) + if (mimeType == MimeTypes.VIDEO_H265 && !requiresSecureDecoder && !requiresTunnelingDecoder) { + if (infos.size > 0) { + val infosNew = infos.find { it.name == "c2.android.hevc.decoder" } + ?.let { mutableListOf(it) } + if (infosNew != null) { + return infosNew + } + } + } + return infos + } + } + + override fun surfaceCreated(holder: SurfaceHolder) { + val renderersFactory = + context?.let { com.google.android.exoplayer2.DefaultRenderersFactory(it) } + val exoplayerMediaCodecSelector = ExoplayerMediaCodecSelector() + renderersFactory?.setMediaCodecSelector(exoplayerMediaCodecSelector) + + exoPlayer = SimpleExoPlayer.Builder(requireContext(), renderersFactory!!).build() + exoPlayer?.setVideoSurfaceHolder(holder) + exoPlayer?.playWhenReady = true + exoPlayer?.addListener(object : com.google.android.exoplayer2.Player.EventListener { + override fun onPlayerError(error: com.google.android.exoplayer2.ExoPlaybackException) { + super.onPlayerError(error) + tvModel?.setReady() + } + }) + exoPlayer?.addVideoListener(object : com.google.android.exoplayer2.video.VideoListener { + override fun onVideoSizeChanged( + width: Int, height: Int, unappliedRotationDegrees: Int, pixelWidthHeightRatio: Float + ) { + val ratio = surfaceView.measuredWidth.div(surfaceView.measuredHeight) + val layoutParams = surfaceView.layoutParams + if (ratio < aspectRatio) { + layoutParams?.height = + (surfaceView.measuredWidth.div(aspectRatio)).toInt() + surfaceView.layoutParams = layoutParams + } else if (ratio > aspectRatio) { + layoutParams?.width = + (surfaceView.measuredHeight.times(aspectRatio)).toInt() + surfaceView.layoutParams = layoutParams + } + } + }) + Log.i(TAG, "exoPlayer ready") + ready() + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + } + + override fun onStart() { + Log.i(TAG, "onStart") + super.onStart() + if (player?.isPlaying == false) { + Log.i(TAG, "replay") + player?.prepare() + player?.play() + } + if (exoPlayer?.isPlaying == false) { + Log.i(TAG, "replay") + exoPlayer?.prepare() + exoPlayer?.play() + } + } + + override fun onPause() { + super.onPause() + if (player?.isPlaying == true) { + player?.stop() + } + if (exoPlayer?.isPlaying == true) { + exoPlayer?.stop() + } + } + + override fun onDestroy() { + super.onDestroy() + player?.release() + exoPlayer?.release() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val TAG = "PlayerFragment" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/SP.kt b/app/src/main/java/com/lizongying/mytv0/SP.kt new file mode 100644 index 00000000..856e2c5e --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/SP.kt @@ -0,0 +1,59 @@ +package com.lizongying.mytv0 + + +import android.content.Context +import android.content.SharedPreferences + +object SP { + // If Change channel with up and down in reversed order or not + private const val KEY_CHANNEL_REVERSAL = "channel_reversal" + + // If use channel num to select channel or not + private const val KEY_CHANNEL_NUM = "channel_num" + + // If start app on device boot or not + private const val KEY_BOOT_STARTUP = "boot_startup" + + // Position in list of the selected channel item + private const val KEY_POSITION = "position" + + private const val KEY_POSITION_CATEGORY = "position_category" + + private const val KEY_POSITION_SUB = "position_sub" + + private lateinit var sp: SharedPreferences + + /** + * The method must be invoked as early as possible(At least before using the keys) + */ + fun init(context: Context) { + sp = context.getSharedPreferences( + context.resources.getString(R.string.app_name), + Context.MODE_PRIVATE + ) + } + + var channelReversal: Boolean + get() = sp.getBoolean(KEY_CHANNEL_REVERSAL, false) + set(value) = sp.edit().putBoolean(KEY_CHANNEL_REVERSAL, value).apply() + + var channelNum: Boolean + get() = sp.getBoolean(KEY_CHANNEL_NUM, true) + set(value) = sp.edit().putBoolean(KEY_CHANNEL_NUM, value).apply() + + var bootStartup: Boolean + get() = sp.getBoolean(KEY_BOOT_STARTUP, false) + set(value) = sp.edit().putBoolean(KEY_BOOT_STARTUP, value).apply() + + var position: Int + get() = sp.getInt(KEY_POSITION, 0) + set(value) = sp.edit().putInt(KEY_POSITION, value).apply() + + var positionCategory: Int + get() = sp.getInt(KEY_POSITION_CATEGORY, 0) + set(value) = sp.edit().putInt(KEY_POSITION_CATEGORY, value).apply() + + var positionSub: Int + get() = sp.getInt(KEY_POSITION_SUB, 0) + set(value) = sp.edit().putInt(KEY_POSITION_SUB, value).apply() +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/SettingFragment.kt b/app/src/main/java/com/lizongying/mytv0/SettingFragment.kt new file mode 100644 index 00000000..d0626b4b --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/SettingFragment.kt @@ -0,0 +1,76 @@ +package com.lizongying.mytv0 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.lizongying.mytv0.databinding.SettingBinding + + +class SettingFragment( + private val versionName: String, + private val versionCode: Long, +) : + Fragment() { + + private var _binding: SettingBinding? = null + private val binding get() = _binding!! + +// private lateinit var updateManager: UpdateManager + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = SettingBinding.inflate(inflater, container, false) + + _binding?.version?.text = + "当前版本: $versionName\n获取最新: https://github.com/lizongying/my-tv/releases/" + + val switchChannelReversal = _binding?.switchChannelReversal + switchChannelReversal?.isChecked = SP.channelReversal + switchChannelReversal?.setOnCheckedChangeListener { _, isChecked -> + SP.channelReversal = isChecked + (activity as MainActivity).settingActive() + } + + val switchChannelNum = _binding?.switchChannelNum + switchChannelNum?.isChecked = SP.channelNum + switchChannelNum?.setOnCheckedChangeListener { _, isChecked -> + SP.channelNum = isChecked + (activity as MainActivity).settingActive() + } + + val switchBootStartup = _binding?.switchBootStartup + switchBootStartup?.isChecked = SP.bootStartup + switchBootStartup?.setOnCheckedChangeListener { _, isChecked -> + SP.bootStartup = isChecked + (activity as MainActivity).settingActive() + } + +// _binding?.checkVersion?.setOnClickListener(OnClickListenerCheckVersion(updateManager)) + + return binding.root + } + + fun setVersionName(versionName: String) { + binding.versionName.text = versionName + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onDestroy() { + super.onDestroy() +// updateManager.destroy() + } + + companion object { + const val TAG = "SettingFragment" + } +} + diff --git a/app/src/main/java/com/lizongying/mytv0/Utils.kt b/app/src/main/java/com/lizongying/mytv0/Utils.kt new file mode 100644 index 00000000..5d082073 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/Utils.kt @@ -0,0 +1,79 @@ +package com.lizongying.mytv0 + +import android.content.Context +import android.content.res.Resources +import android.os.Build +import android.util.TypedValue +import com.google.gson.Gson +import com.lizongying.mytv0.requests.TimeResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object Utils { + private var between: Long = 0 + + fun getDateFormat(format: String): String { + return SimpleDateFormat( + format, + Locale.CHINA + ).format(Date(System.currentTimeMillis() - between)) + } + + fun getDateTimestamp(): Long { + return (System.currentTimeMillis() - between) / 1000 + } + + suspend fun init() { + var currentTimeMillis: Long = 0 + try { + currentTimeMillis = getTimestampFromServer() + } catch (e: Exception) { + println("Failed to retrieve timestamp from server: ${e.message}") + } + between = System.currentTimeMillis() - currentTimeMillis + } + + /** + * 从服务器获取时间戳 + * @return Long 时间戳 + */ + private suspend fun getTimestampFromServer(): Long { + return withContext(Dispatchers.IO) { + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(500, java.util.concurrent.TimeUnit.MILLISECONDS) + .readTimeout(1, java.util.concurrent.TimeUnit.SECONDS).build() + val request = okhttp3.Request.Builder() + .url("https://api.m.taobao.com/rest/api3.do?api=mtop.common.getTimestamp") + .build() + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("Unexpected code $response") + val string = response.body()?.string() + Gson().fromJson(string, TimeResponse::class.java).data.t.toLong() + } + } catch (e: IOException) { + // Handle network errors + throw IOException("Error during network request", e) + } + } + } + + fun dpToPx(dp: Float): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, dp, Resources.getSystem().displayMetrics + ).toInt() + } + + fun dpToPx(dp: Int): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), Resources.getSystem().displayMetrics + ).toInt() + } + + fun isTmallDevice() = Build.MANUFACTURER.equals("Tmall", ignoreCase = true) +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/models/Program.kt b/app/src/main/java/com/lizongying/mytv0/models/Program.kt new file mode 100644 index 00000000..cc55705e --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/models/Program.kt @@ -0,0 +1,22 @@ +package com.lizongying.mytv0.models + +import java.io.Serializable + +data class Program( + var id: Int = 0, + var title: String = "", + var description: String? = null, + var logo: String = "", + var image: String? = null, +) : Serializable { + + override fun toString(): String { + return "Program{" + + "id=" + id + + ", title='" + title + '\'' + + ", description='" + description + '\'' + + ", logo='" + logo + '\'' + + ", image='" + image + '\'' + + '}' + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/models/TV.kt b/app/src/main/java/com/lizongying/mytv0/models/TV.kt new file mode 100644 index 00000000..ed831c9c --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/models/TV.kt @@ -0,0 +1,30 @@ +package com.lizongying.mytv0.models + +import java.io.Serializable + +data class TV( + var id: Int = 0, + var programId: String = "", + var title: String = "", + var description: String? = null, + var logo: String = "", + var image: String? = null, + var videoUrl: List, + var headers: Map? = null, + var category: String = "", + var child: List, +) : Serializable { + + override fun toString(): String { + return "TV{" + + "id=" + id + + ", programId='" + programId + '\'' + + ", title='" + title + '\'' + + ", description='" + description + '\'' + + ", logo='" + logo + '\'' + + ", image='" + image + '\'' + + ", videoUrl='" + videoUrl + '\'' + + ", category='" + category + '\'' + + '}' + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/models/TVCategoryModel.kt b/app/src/main/java/com/lizongying/mytv0/models/TVCategoryModel.kt new file mode 100644 index 00000000..c61010b4 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/models/TVCategoryModel.kt @@ -0,0 +1,45 @@ +package com.lizongying.mytv0.models + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.lizongying.mytv0.SP + +class TVCategoryModel : ViewModel() { + private val _tvCategoryModel = MutableLiveData>() + val tvCategoryModel: LiveData> + get() = _tvCategoryModel + + private val _position = MutableLiveData() + val position: LiveData + get() = _position + + fun setPosition(position: Int) { + _position.value = position + } + + fun addTVListModel(tvListModel: TVListModel) { + if (_tvCategoryModel.value == null) { + _tvCategoryModel.value = mutableListOf(tvListModel) + return + } + _tvCategoryModel.value?.add(tvListModel) + } + + fun getTVListModel(idx: Int): TVListModel? { + return _tvCategoryModel.value?.get(idx) + } + + init { + _position.value = SP.positionCategory + _position.value = 2 + } + + fun size(): Int { + if (_tvCategoryModel.value == null) { + return 0 + } + + return _tvCategoryModel.value!!.size + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/models/TVList.kt b/app/src/main/java/com/lizongying/mytv0/models/TVList.kt new file mode 100644 index 00000000..7efaa580 --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/models/TVList.kt @@ -0,0 +1,109 @@ +package com.lizongying.mytv0.models + +import android.content.Context +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.lizongying.mytv0.R +import java.io.File + +object TVList { + private const val FILE_NAME = "channels.json" + private lateinit var appDirectory: File + private lateinit var serverUrl: String + private lateinit var list: List + lateinit var listModel: List + lateinit var categoryModel: TVCategoryModel + + private val _position = MutableLiveData() + val position: LiveData + get() = _position + + fun init(context: Context) { + _position.value = 0 + appDirectory = context.filesDir + serverUrl = context.resources.getString(R.string.server_url) + val file = File(appDirectory, FILE_NAME) + val str = if (file.exists()) { + file.readText() + } else { + context.resources.openRawResource(R.raw.channels).bufferedReader() + .use { it.readText() } + } + str2List(str) + } + + fun update() { + val client = okhttp3.OkHttpClient() + val request = okhttp3.Request.Builder().url(serverUrl).build() + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val file = File(appDirectory, FILE_NAME) + if (!file.exists()) { + file.createNewFile() + } + val str = response.body()!!.string() + file.writeText(str) + str2List(str) + } + } + } + + private fun str2List(str: String) { + val type = object : com.google.gson.reflect.TypeToken>() {}.type + list = com.google.gson.Gson().fromJson(str, type) + Log.i("TVList", "$list") + + listModel = list.map { tv -> + TVModel(tv) + } + + val category: MutableList = mutableListOf() + + var tvListModel = TVListModel("我的收藏") + category.add(tvListModel) + + tvListModel = TVListModel("全部频道") + tvListModel.setTVListModel(listModel) + category.add(tvListModel) + + val map: MutableMap> = mutableMapOf() + for ((id, v) in list.withIndex()) { + if (v.category !in map) { + map[v.category] = mutableListOf() + } + v.id = id + map[v.category]?.add(TVModel(v)) + } + + for ((k, v) in map) { + tvListModel = TVListModel(k) + for (v1 in v) { + tvListModel.addTVModel(v1) + } + category.add(tvListModel) + } + + categoryModel = TVCategoryModel() + for (v in category) { + categoryModel.addTVListModel(v) + } + } + + fun getTVModel(idx: Int): TVModel { + return listModel[idx] + } + + fun setPosition(position: Int) { + if (_position.value != position) { + _position.value = position + } + + // set a new position or retry when position same + listModel[position].setReady() + } + + fun size(): Int { + return listModel.size + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/models/TVListModel.kt b/app/src/main/java/com/lizongying/mytv0/models/TVListModel.kt new file mode 100644 index 00000000..2cccac4c --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/models/TVListModel.kt @@ -0,0 +1,59 @@ +package com.lizongying.mytv0.models + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class TVListModel(private val name: String) : ViewModel() { + fun getName(): String { + return name + } + + private val _tvListModel = MutableLiveData>() + val tvListModel: LiveData> + get() = _tvListModel + + private val _position = MutableLiveData() + val position: LiveData + get() = _position + + fun setPosition(position: Int) { + _position.value = position + } + + private val _change = MutableLiveData() + val change: LiveData + get() = _change + + fun setChange() { + _change.value = true + } + + fun setTVListModel(tvListModel: List) { + _tvListModel.value = tvListModel.toMutableList() + } + + fun addTVModel(tvModel: TVModel) { + if (_tvListModel.value == null) { + _tvListModel.value = mutableListOf(tvModel) + return + } + _tvListModel.value?.add(tvModel) + } + + fun getTVModel(idx: Int): TVModel? { + return _tvListModel.value?.get(idx) + } + + init { + _position.value = 0 + } + + fun size(): Int { + if (_tvListModel.value == null) { + return 0 + } + + return _tvListModel.value!!.size + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/models/TVModel.kt b/app/src/main/java/com/lizongying/mytv0/models/TVModel.kt new file mode 100644 index 00000000..d777d86c --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/models/TVModel.kt @@ -0,0 +1,82 @@ +package com.lizongying.mytv0.models + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.hls.HlsMediaSource + +class TVModel(var tv: TV) : ViewModel() { + private val _position = MutableLiveData() + val position: LiveData + get() = _position + + var retryTimes = 0 + var retryMaxTimes = 8 + var programUpdateTime = 0L + + private val _errInfo = MutableLiveData() + val errInfo: LiveData + get() = _errInfo + + fun setErrInfo(info: String) { + _errInfo.value = info + } + + private var _program = MutableLiveData>() + val program: LiveData> + get() = _program + + private val _videoUrl = MutableLiveData() + val videoUrl: LiveData + get() = _videoUrl + + fun setVideoUrl(url: String) { + _videoUrl.value = url + } + + fun getVideoUrl(): String? { + return _videoIndex.value?.let { tv.videoUrl[it] } + } + + private val _ready = MutableLiveData() + val ready: LiveData + get() = _ready + + fun setReady() { + _ready.value = true + } + + private val _videoIndex = MutableLiveData() + val videoIndex: LiveData + get() = _videoIndex + + init { + _position.value = 0 + _videoIndex.value = 0 + _videoUrl.value = getVideoUrl() + _program.value = mutableListOf() + } + + fun update(t: TV) { + tv = t + } + + @OptIn(UnstableApi::class) + fun buildSource(): HlsMediaSource { + val httpDataSource = DefaultHttpDataSource.Factory() + tv.headers?.let { httpDataSource.setDefaultRequestProperties(it) } + + return HlsMediaSource.Factory(httpDataSource).createMediaSource( + MediaItem.fromUri(_videoUrl.value!!) + ) + } + + companion object { + private const val TAG = "TVModel" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lizongying/mytv0/requests/TimeResponse.kt b/app/src/main/java/com/lizongying/mytv0/requests/TimeResponse.kt new file mode 100644 index 00000000..7a66b57d --- /dev/null +++ b/app/src/main/java/com/lizongying/mytv0/requests/TimeResponse.kt @@ -0,0 +1,10 @@ +package com.lizongying.mytv0.requests + + +data class TimeResponse( + val data: Time +) { + data class Time( + val t: String + ) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/appreciate.jpg b/app/src/main/res/drawable/appreciate.jpg new file mode 100644 index 00000000..751634ac Binary files /dev/null and b/app/src/main/res/drawable/appreciate.jpg differ diff --git a/app/src/main/res/drawable/banner0.png b/app/src/main/res/drawable/banner0.png new file mode 100644 index 00000000..fcc47d2c Binary files /dev/null and b/app/src/main/res/drawable/banner0.png differ diff --git a/app/src/main/res/drawable/default_background.xml b/app/src/main/res/drawable/default_background.xml new file mode 100644 index 00000000..82e51a2b --- /dev/null +++ b/app/src/main/res/drawable/default_background.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/logo0.png b/app/src/main/res/drawable/logo0.png new file mode 100644 index 00000000..424e0e28 Binary files /dev/null and b/app/src/main/res/drawable/logo0.png differ diff --git a/app/src/main/res/drawable/rounded_background.xml b/app/src/main/res/drawable/rounded_background.xml new file mode 100644 index 00000000..23bb6333 --- /dev/null +++ b/app/src/main/res/drawable/rounded_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_background2.xml b/app/src/main/res/drawable/rounded_background2.xml new file mode 100644 index 00000000..a6c40567 --- /dev/null +++ b/app/src/main/res/drawable/rounded_background2.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..86bbdaec --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/category_item.xml b/app/src/main/res/layout/category_item.xml new file mode 100644 index 00000000..b42540d8 --- /dev/null +++ b/app/src/main/res/layout/category_item.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/channel.xml b/app/src/main/res/layout/channel.xml new file mode 100644 index 00000000..7c48bd52 --- /dev/null +++ b/app/src/main/res/layout/channel.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/info.xml b/app/src/main/res/layout/info.xml new file mode 100644 index 00000000..25e2813f --- /dev/null +++ b/app/src/main/res/layout/info.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_item.xml b/app/src/main/res/layout/list_item.xml new file mode 100644 index 00000000..489c11a3 --- /dev/null +++ b/app/src/main/res/layout/list_item.xml @@ -0,0 +1,44 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/menu.xml b/app/src/main/res/layout/menu.xml new file mode 100644 index 00000000..10dd4058 --- /dev/null +++ b/app/src/main/res/layout/menu.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/player.xml b/app/src/main/res/layout/player.xml new file mode 100644 index 00000000..063834b5 --- /dev/null +++ b/app/src/main/res/layout/player.xml @@ -0,0 +1,24 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/setting.xml b/app/src/main/res/layout/setting.xml new file mode 100644 index 00000000..752f3845 --- /dev/null +++ b/app/src/main/res/layout/setting.xml @@ -0,0 +1,80 @@ + + + + + + + +