diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore
new file mode 100644
index 0000000..9de0f16
--- /dev/null
+++ b/.codegraph/.gitignore
@@ -0,0 +1,16 @@
+# CodeGraph data files
+# These are local to each machine and should not be committed
+
+# Database
+*.db
+*.db-wal
+*.db-shm
+
+# Cache
+cache/
+
+# Logs
+*.log
+
+# Hook markers
+.dirty
diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid
new file mode 100644
index 0000000..2294ab6
--- /dev/null
+++ b/.codegraph/daemon.pid
@@ -0,0 +1,6 @@
+{
+ "pid": 38368,
+ "version": "0.9.7",
+ "socketPath": "\\\\.\\pipe\\codegraph-cb339f1c8a0989e3",
+ "startedAt": 1781679324951
+}
diff --git a/.reasonix/desktop-topic-created-at.json b/.reasonix/desktop-topic-created-at.json
new file mode 100644
index 0000000..02eda7d
--- /dev/null
+++ b/.reasonix/desktop-topic-created-at.json
@@ -0,0 +1,3 @@
+{
+ "topic_20260617-065519_fbde6a25cff2683a": 1781679319697
+}
\ No newline at end of file
diff --git a/.reasonix/desktop-topic-title-sources.json b/.reasonix/desktop-topic-title-sources.json
new file mode 100644
index 0000000..25da995
--- /dev/null
+++ b/.reasonix/desktop-topic-title-sources.json
@@ -0,0 +1,3 @@
+{
+ "topic_20260617-065519_fbde6a25cff2683a": "auto"
+}
\ No newline at end of file
diff --git a/.reasonix/desktop-topic-titles.json b/.reasonix/desktop-topic-titles.json
new file mode 100644
index 0000000..d30ee2f
--- /dev/null
+++ b/.reasonix/desktop-topic-titles.json
@@ -0,0 +1,3 @@
+{
+ "topic_20260617-065519_fbde6a25cff2683a": "我要给电视盒子做一个非常简单的APP…"
+}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6d95ab4..51bd9b7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -27,11 +27,14 @@ android {
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ viewBinding = true
}
}
@@ -42,7 +45,10 @@ dependencies {
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.exoplayer.hls)
+ implementation(libs.androidx.media3.ui)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 976cbd4..00f22fe 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,12 @@
+
+
+
+
+
+
+
+ android:exported="true"
+ android:excludeFromRecents="false"
+ android:launchMode="singleTask"
+ android:screenOrientation="landscape"
+ android:configChanges="orientation|keyboardHidden|screenSize">
-
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/cn/flyzxf/FengTv/BootReceiver.kt b/app/src/main/java/cn/flyzxf/FengTv/BootReceiver.kt
new file mode 100644
index 0000000..7a175d2
--- /dev/null
+++ b/app/src/main/java/cn/flyzxf/FengTv/BootReceiver.kt
@@ -0,0 +1,15 @@
+package cn.flyzxf.FengTv
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ launchIntent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(launchIntent)
+ }
+ }
+}
diff --git a/app/src/main/java/cn/flyzxf/FengTv/ChannelInfo.kt b/app/src/main/java/cn/flyzxf/FengTv/ChannelInfo.kt
new file mode 100644
index 0000000..929a559
--- /dev/null
+++ b/app/src/main/java/cn/flyzxf/FengTv/ChannelInfo.kt
@@ -0,0 +1,6 @@
+package cn.flyzxf.FengTv
+
+data class ChannelInfo(
+ val name: String,
+ val url: String
+)
diff --git a/app/src/main/java/cn/flyzxf/FengTv/ChannelManager.kt b/app/src/main/java/cn/flyzxf/FengTv/ChannelManager.kt
new file mode 100644
index 0000000..3ecd7e0
--- /dev/null
+++ b/app/src/main/java/cn/flyzxf/FengTv/ChannelManager.kt
@@ -0,0 +1,101 @@
+package cn.flyzxf.FengTv
+
+import android.content.Context
+import android.content.SharedPreferences
+import java.io.BufferedReader
+import java.io.File
+import java.io.FileReader
+import java.io.FileWriter
+
+object ChannelManager {
+ private const val PREFS_NAME = "fengtv_prefs"
+ private const val KEY_SOURCE_URL = "source_url"
+ private const val KEY_CURRENT_INDEX = "current_index"
+ private const val CHANNEL_FILE = "channels.txt"
+
+ private var channels: MutableList = mutableListOf()
+ var currentIndex: Int = 0
+ private set
+
+ fun loadChannels(context: Context): List {
+ val file = File(context.filesDir, CHANNEL_FILE)
+ channels.clear()
+ if (file.exists()) {
+ try {
+ BufferedReader(FileReader(file)).use { reader ->
+ reader.forEachLine { line ->
+ val trimmed = line.trim()
+ if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) {
+ val commaIdx = trimmed.indexOf(',')
+ if (commaIdx > 0 && commaIdx < trimmed.length - 1) {
+ val name = trimmed.substring(0, commaIdx).trim()
+ val url = trimmed.substring(commaIdx + 1).trim()
+ if (url.isNotEmpty()) {
+ channels.add(ChannelInfo(name, url))
+ }
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ val prefs = getPrefs(context)
+ currentIndex = prefs.getInt(KEY_CURRENT_INDEX, 0)
+ if (currentIndex >= channels.size) currentIndex = 0
+ return channels.toList()
+ }
+
+ fun saveChannels(context: Context, lines: List) {
+ val file = File(context.filesDir, CHANNEL_FILE)
+ try {
+ FileWriter(file).use { writer ->
+ lines.forEach { line ->
+ writer.write(line + "\n")
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ loadChannels(context)
+ }
+
+ fun getSavedSourceUrl(context: Context): String? {
+ return getPrefs(context).getString(KEY_SOURCE_URL, null)
+ }
+
+ fun saveSourceUrl(context: Context, url: String) {
+ getPrefs(context).edit().putString(KEY_SOURCE_URL, url).apply()
+ }
+
+ fun saveCurrentIndex(context: Context, index: Int) {
+ currentIndex = index
+ getPrefs(context).edit().putInt(KEY_CURRENT_INDEX, index).apply()
+ }
+
+ fun getChannelCount(): Int = channels.size
+
+ fun getChannel(index: Int): ChannelInfo? {
+ if (index in channels.indices) return channels[index]
+ return null
+ }
+
+ fun getCurrentChannel(): ChannelInfo? = getChannel(currentIndex)
+
+ fun nextChannel(): ChannelInfo? {
+ if (channels.isEmpty()) return null
+ currentIndex = (currentIndex + 1) % channels.size
+ return channels[currentIndex]
+ }
+
+ fun prevChannel(): ChannelInfo? {
+ if (channels.isEmpty()) return null
+ currentIndex = if (currentIndex - 1 < 0) channels.size - 1 else currentIndex - 1
+ return channels[currentIndex]
+ }
+
+ private fun getPrefs(context: Context): SharedPreferences {
+ return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/cn/flyzxf/FengTv/ConfigDialogFragment.kt b/app/src/main/java/cn/flyzxf/FengTv/ConfigDialogFragment.kt
new file mode 100644
index 0000000..9ebb9a8
--- /dev/null
+++ b/app/src/main/java/cn/flyzxf/FengTv/ConfigDialogFragment.kt
@@ -0,0 +1,106 @@
+package cn.flyzxf.FengTv
+
+import android.app.AlertDialog
+import android.app.Dialog
+import android.net.Uri
+import android.os.Bundle
+import android.text.InputType
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.DialogFragment
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.net.URL
+
+class ConfigDialogFragment : DialogFragment() {
+
+ private val filePickerLauncher = registerForActivityResult(
+ ActivityResultContracts.OpenDocument()
+ ) { uri: Uri? ->
+ uri?.let { handleFileUri(it) }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val items = arrayOf("输入 M3U8 源链接", "选择本地 TXT 文件", "取消")
+
+ return AlertDialog.Builder(requireActivity())
+ .setTitle("设置电视源")
+ .setItems(items) { _, which ->
+ when (which) {
+ 0 -> showUrlInputDialog()
+ 1 -> filePickerLauncher.launch(arrayOf("text/plain"))
+ }
+ }
+ .create()
+ }
+
+ private fun showUrlInputDialog() {
+ // 提前捕获 activity 引用,防止回调时 Fragment 已被销毁导致 IllegalStateException
+ val activity = requireActivity() as MainActivity
+ val context = requireContext()
+
+ val input = EditText(context)
+ input.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
+ input.hint = "https://example.com/channels.txt"
+
+ val layout = LinearLayout(context).apply {
+ setPadding(50, 20, 50, 20)
+ addView(input, LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ))
+ }
+
+ AlertDialog.Builder(activity)
+ .setTitle("输入 M3U8 源链接")
+ .setView(layout)
+ .setPositiveButton("确定") { _, _ ->
+ val url = input.text.toString().trim()
+ if (url.isNotEmpty()) {
+ // 简单验证 URL 格式
+ if (!isValidUrl(url)) {
+ Toast.makeText(context, "链接格式不正确,请检查后重试", Toast.LENGTH_LONG).show()
+ return@setPositiveButton
+ }
+ activity.onConfigSaved(url)
+ }
+ }
+ .setNegativeButton("取消", null)
+ .show()
+ }
+
+ private fun isValidUrl(url: String): Boolean {
+ return try {
+ val u = URL(url)
+ u.protocol == "http" || u.protocol == "https"
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ private fun handleFileUri(uri: Uri) {
+ try {
+ // 提前捕获引用
+ val context = requireContext()
+ val activity = requireActivity() as MainActivity
+
+ val inputStream = context.contentResolver.openInputStream(uri)
+ val lines = mutableListOf()
+ BufferedReader(InputStreamReader(inputStream, "UTF-8")).use { reader ->
+ reader.forEachLine { lines.add(it) }
+ }
+ ChannelManager.saveChannels(context, lines)
+ activity.startPlayback()
+ Toast.makeText(context, "已加载本地文件", Toast.LENGTH_SHORT).show()
+ } catch (e: Exception) {
+ // 此时如果 Fragment 已分离,requireContext() 可能抛异常,用安全方式获取
+ try {
+ Toast.makeText(requireContext(), "读取文件失败", Toast.LENGTH_LONG).show()
+ } catch (e2: Exception) {
+ // 忽略,Fragment 已销毁
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/cn/flyzxf/FengTv/MainActivity.kt b/app/src/main/java/cn/flyzxf/FengTv/MainActivity.kt
index f38eb25..483a6bb 100644
--- a/app/src/main/java/cn/flyzxf/FengTv/MainActivity.kt
+++ b/app/src/main/java/cn/flyzxf/FengTv/MainActivity.kt
@@ -1,20 +1,592 @@
package cn.flyzxf.FengTv
+import android.content.SharedPreferences
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.Uri
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.KeyEvent
+import android.view.View
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.ImageButton
+import android.widget.ListView
+import android.widget.TextView
+import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.AspectRatioFrameLayout
+import androidx.media3.ui.PlayerView
+import java.io.BufferedReader
+import java.io.File
+import java.io.FileReader
+import java.io.InputStreamReader
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.Executors
class MainActivity : AppCompatActivity() {
+ private lateinit var playerView: PlayerView
+ private lateinit var channelNameOverlay: TextView
+ private lateinit var loadingOverlay: View
+ private lateinit var loadingText: TextView
+ private lateinit var bottomToolbar: View
+ private lateinit var channelPanel: View
+ private lateinit var channelListView: ListView
+ private lateinit var toolbarIndicator: TextView
+ private lateinit var btnPrev: ImageButton
+ private lateinit var btnNext: ImageButton
+ private lateinit var btnToggleReverse: ImageButton
+ private lateinit var btnShowList: ImageButton
+ private lateinit var btnSettings: ImageButton
+ private lateinit var channelPanelClose: TextView
+ private lateinit var switchingIndicator: View
+ private lateinit var switchingChannelNum: TextView
+ private lateinit var switchingChannelTotal: TextView
+ private lateinit var switchingStatus: TextView
+ private var player: ExoPlayer? = null
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val executor = Executors.newSingleThreadExecutor()
+ private var retryCount = 0
+ private val maxRetries = 3
+ private var isFirstLaunch = true
+ private var hasShownNetworkRetryToast = false
+ private var isReverseOrder = false
+ private var isToolbarShowing = false
+
+ // 频道名消失 Runnable
+ private val hideChannelNameRunnable = Runnable {
+ channelNameOverlay.visibility = View.GONE
+ }
+ // 工具栏消失 Runnable
+ private val hideToolbarRunnable = Runnable {
+ hideToolbar()
+ }
+
+ companion object {
+ private const val PREFS_NAME = "fengtv_prefs"
+ private const val KEY_SOURCE_URL = "source_url"
+ private const val KEY_FIRST_LAUNCH = "first_launch"
+ private const val KEY_HAS_CONFIG = "has_config"
+ private const val KEY_REVERSE = "reverse_order"
+ private const val TIMEOUT_MS = 3000
+ private const val CHANNEL_NAME_DURATION_MS = 3000L
+ private const val NETWORK_DELAY_MS = 5000L
+ private const val TOOLBAR_DURATION_MS = 5000L
+ private const val LOADING_TIMEOUT_MS = 4000L
+ private const val SWITCH_STATUS_DURATION_MS = 2000L
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
+
+ playerView = findViewById(R.id.player_view)
+ channelNameOverlay = findViewById(R.id.channel_name_overlay)
+ loadingOverlay = findViewById(R.id.loading_overlay)
+ loadingText = findViewById(R.id.loading_text)
+ bottomToolbar = findViewById(R.id.bottom_toolbar)
+ channelPanel = findViewById(R.id.channel_panel)
+ channelListView = findViewById(R.id.channel_list_view)
+ toolbarIndicator = findViewById(R.id.toolbar_channel_indicator)
+ btnPrev = findViewById(R.id.btn_prev_channel)
+ btnNext = findViewById(R.id.btn_next_channel)
+ btnToggleReverse = findViewById(R.id.btn_toggle_reverse)
+ btnShowList = findViewById(R.id.btn_show_list)
+ btnSettings = findViewById(R.id.btn_settings)
+ channelPanelClose = findViewById(R.id.channel_panel_close)
+ switchingIndicator = findViewById(R.id.switching_indicator)
+ switchingChannelNum = findViewById(R.id.switching_channel_num)
+ switchingChannelTotal = findViewById(R.id.switching_channel_total)
+ switchingStatus = findViewById(R.id.switching_status)
+
+ playerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT)
+
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
+
+ // 读取反转设置
+ val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+ isReverseOrder = prefs.getBoolean(KEY_REVERSE, false)
+ updateReverseButton()
+
+ registerNetworkCallback()
+ setupControls()
+
+ isFirstLaunch = prefs.getBoolean(KEY_FIRST_LAUNCH, true)
+
+ if (isFirstLaunch) {
+ prefs.edit().putBoolean(KEY_FIRST_LAUNCH, false).apply()
+ showConfigDialog()
+ } else {
+ startPlayback()
+ }
}
-}
\ No newline at end of file
+
+ // ======================== 控件绑定 ========================
+
+ private fun setupControls() {
+ // 点击画面切换工具栏
+ findViewById(R.id.main).setOnClickListener {
+ toggleToolbar()
+ }
+
+ // 上一台
+ btnPrev.setOnClickListener { switchChannel(false) }
+ btnPrev.setOnLongClickListener {
+ showChannelPanel()
+ true
+ }
+
+ // 下一台
+ btnNext.setOnClickListener { switchChannel(true) }
+ btnNext.setOnLongClickListener {
+ showChannelPanel()
+ true
+ }
+
+ // 换台反转
+ btnToggleReverse.setOnClickListener {
+ isReverseOrder = !isReverseOrder
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+ .edit().putBoolean(KEY_REVERSE, isReverseOrder).apply()
+ updateReverseButton()
+ Toast.makeText(this, if (isReverseOrder) "已反转换台方向" else "已恢复默认方向", Toast.LENGTH_SHORT).show()
+ refreshToolbar()
+ }
+
+ // 频道列表
+ btnShowList.setOnClickListener { showChannelPanel() }
+
+ // 设置
+ btnSettings.setOnClickListener { showConfigDialog() }
+
+ // 频道面板关闭
+ channelPanelClose.setOnClickListener { hideChannelPanel() }
+
+ // 频道列表点击
+ channelListView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
+ val channels = ChannelManager.loadChannels(this)
+ if (position in channels.indices) {
+ ChannelManager.saveCurrentIndex(this@MainActivity, position)
+ playChannel(channels[position])
+ hideChannelPanel()
+ }
+ }
+ }
+
+ // ======================== 工具栏 ========================
+
+ private fun toggleToolbar() {
+ if (channelPanel.visibility == View.VISIBLE) return
+ if (isToolbarShowing) {
+ hideToolbar()
+ } else {
+ showToolbar()
+ }
+ }
+
+ private fun showToolbar() {
+ refreshToolbar()
+ bottomToolbar.visibility = View.VISIBLE
+ isToolbarShowing = true
+ mainHandler.removeCallbacks(hideToolbarRunnable)
+ mainHandler.postDelayed(hideToolbarRunnable, TOOLBAR_DURATION_MS)
+ }
+
+ private fun hideToolbar() {
+ bottomToolbar.visibility = View.GONE
+ isToolbarShowing = false
+ mainHandler.removeCallbacks(hideToolbarRunnable)
+ }
+
+ private fun refreshToolbar() {
+ val channel = ChannelManager.getCurrentChannel()
+ val total = ChannelManager.getChannelCount()
+ val num = ChannelManager.currentIndex + 1
+ toolbarIndicator.text = if (channel != null) "$num/$total ${channel.name}" else "无频道"
+ }
+
+ // ======================== 频道列表面板 ========================
+
+ private fun showChannelPanel() {
+ hideToolbar()
+ val channels = ChannelManager.loadChannels(this)
+ if (channels.isEmpty()) {
+ Toast.makeText(this, "没有频道", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val names = channels.mapIndexed { index, ch ->
+ val prefix = if (index == ChannelManager.currentIndex) "▶ " else " "
+ "$prefix${index + 1}. ${ch.name}"
+ }
+ val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, names)
+ channelListView.adapter = adapter
+ channelListView.setSelection(ChannelManager.currentIndex)
+
+ channelPanel.visibility = View.VISIBLE
+ channelListView.requestFocus()
+ }
+
+ private fun hideChannelPanel() {
+ channelPanel.visibility = View.GONE
+ }
+
+ // ======================== 反转按钮 ========================
+
+ private fun updateReverseButton() {
+ btnToggleReverse.alpha = if (isReverseOrder) 1.0f else 0.4f
+ }
+
+ // ======================== 按键 & 触摸 ========================
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
+ return when (keyCode) {
+ KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_CHANNEL_UP -> {
+ if (channelPanel.visibility == View.VISIBLE) {
+ event?.let { channelListView.dispatchKeyEvent(it) }
+ return true
+ }
+ switchChannel(true)
+ true
+ }
+ KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
+ if (channelPanel.visibility == View.VISIBLE) {
+ event?.let { channelListView.dispatchKeyEvent(it) }
+ return true
+ }
+ switchChannel(false)
+ true
+ }
+ KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
+ if (channelPanel.visibility == View.VISIBLE) {
+ // 选中当前项
+ val pos = channelListView.selectedItemPosition
+ if (pos >= 0) {
+ channelListView.performItemClick(null, pos, 0)
+ }
+ return true
+ }
+ showChannelName()
+ true
+ }
+ KeyEvent.KEYCODE_BACK -> {
+ if (channelPanel.visibility == View.VISIBLE) {
+ hideChannelPanel()
+ return true
+ }
+ if (isToolbarShowing) {
+ hideToolbar()
+ return true
+ }
+ super.onKeyDown(keyCode, event)
+ }
+ KeyEvent.KEYCODE_SETTINGS, KeyEvent.KEYCODE_MENU -> {
+ showConfigDialog()
+ true
+ }
+ else -> super.onKeyDown(keyCode, event)
+ }
+ }
+
+ // ======================== 配置 ========================
+
+ private fun showConfigDialog() {
+ val configDialog = ConfigDialogFragment()
+ configDialog.show(supportFragmentManager, "config_dialog")
+ }
+
+ fun onConfigSaved(url: String) {
+ val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+ prefs.edit().putString(KEY_SOURCE_URL, url).putBoolean(KEY_HAS_CONFIG, true).apply()
+ ChannelManager.saveSourceUrl(this, url)
+ startPlayback()
+ }
+
+ fun onLocalFileSelected(filePath: String) {
+ try {
+ val file = File(filePath)
+ if (file.exists()) {
+ val lines = mutableListOf()
+ BufferedReader(FileReader(file)).use { reader ->
+ reader.forEachLine { lines.add(it) }
+ }
+ ChannelManager.saveChannels(this, lines)
+ startPlayback()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(this, "读取文件失败: ${e.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+
+ // ======================== 播放逻辑 ========================
+
+ fun startPlayback() {
+ val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+ val hasConfig = prefs.getBoolean(KEY_HAS_CONFIG, false)
+
+ if (!hasConfig) {
+ showConfigDialog()
+ return
+ }
+
+ hasShownNetworkRetryToast = false
+
+ val channels = ChannelManager.loadChannels(this)
+ if (channels.isNotEmpty()) {
+ playChannel(ChannelManager.getCurrentChannel()!!)
+ } else {
+ loadingOverlay.visibility = View.VISIBLE
+ loadingText.text = "正在加载频道列表..."
+ loadingText.visibility = View.VISIBLE
+ }
+
+ val sourceUrl = ChannelManager.getSavedSourceUrl(this)
+ if (sourceUrl != null) {
+ executor.execute {
+ downloadAndUpdateSource(sourceUrl)
+ }
+ }
+ }
+
+ private fun downloadAndUpdateSource(sourceUrl: String) {
+ try {
+ val url = URL(sourceUrl)
+ val conn = url.openConnection() as HttpURLConnection
+ conn.connectTimeout = TIMEOUT_MS
+ conn.readTimeout = TIMEOUT_MS
+ conn.instanceFollowRedirects = true
+
+ val responseCode = conn.responseCode
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ val lines = mutableListOf()
+ BufferedReader(InputStreamReader(conn.inputStream, "UTF-8")).use { reader ->
+ reader.forEachLine { lines.add(it) }
+ }
+ if (lines.isNotEmpty()) {
+ val channelCountBefore = ChannelManager.getChannelCount()
+ ChannelManager.saveChannels(this, lines)
+ val newCount = ChannelManager.getChannelCount()
+ mainHandler.post {
+ if (channelCountBefore == 0 && newCount > 0) {
+ playChannel(ChannelManager.getCurrentChannel()!!)
+ } else {
+ showChannelName()
+ }
+ }
+ }
+ } else {
+ mainHandler.post {
+ Toast.makeText(this@MainActivity, "下载源失败: HTTP $responseCode", Toast.LENGTH_SHORT).show()
+ }
+ }
+ conn.disconnect()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ if (ChannelManager.getChannelCount() == 0) {
+ if (!hasShownNetworkRetryToast) {
+ hasShownNetworkRetryToast = true
+ mainHandler.post {
+ Toast.makeText(this@MainActivity, "无法连接服务器,将在后台继续尝试...", Toast.LENGTH_LONG).show()
+ }
+ }
+ mainHandler.postDelayed({
+ executor.execute { downloadAndUpdateSource(sourceUrl) }
+ }, NETWORK_DELAY_MS)
+ } else {
+ mainHandler.post {
+ Toast.makeText(this@MainActivity, "更新源失败,使用缓存", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ // 加载超时 Runnable
+ private var loadingTimeoutRunnable: Runnable? = null
+
+ private fun showSwitchingIndicator() {
+ val total = ChannelManager.getChannelCount()
+ val num = ChannelManager.currentIndex + 1
+ switchingChannelNum.text = "$num"
+ switchingChannelTotal.text = "/$total"
+ switchingStatus.text = ""
+ switchingIndicator.visibility = View.VISIBLE
+ }
+
+ private fun showSwitchingStatus(text: String) {
+ switchingStatus.text = text
+ switchingIndicator.visibility = View.VISIBLE
+ }
+
+ private fun hideSwitchingIndicator() {
+ switchingIndicator.visibility = View.GONE
+ }
+
+ private fun startLoadingTimeout() {
+ cancelLoadingTimeout()
+ loadingTimeoutRunnable = Runnable {
+ // 加载超时,自动随机跳到另一个台(避免两个坏台间死循环)
+ showSwitchingStatus("超时,随机切换")
+ player?.stop()
+ mainHandler.postDelayed({
+ autoNextChannel()
+ }, 500L)
+ }
+ loadingTimeoutRunnable?.let {
+ mainHandler.postDelayed(it, LOADING_TIMEOUT_MS)
+ }
+ }
+
+ private fun cancelLoadingTimeout() {
+ loadingTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
+ loadingTimeoutRunnable = null
+ }
+
+ private fun playChannel(channel: ChannelInfo) {
+ loadingOverlay.visibility = View.VISIBLE
+ loadingText.text = "加载中..."
+ loadingText.visibility = View.VISIBLE
+ retryCount = 0
+ hideToolbar()
+
+ // 显示左上角切换指示(频道序号)
+ showSwitchingIndicator()
+
+ // 启动加载超时定时器
+ startLoadingTimeout()
+
+ player?.release()
+ player = ExoPlayer.Builder(this).build().apply {
+ setMediaItem(MediaItem.fromUri(Uri.parse(channel.url)))
+ prepare()
+ playWhenReady = true
+ addListener(object : Player.Listener {
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ if (playbackState == Player.STATE_READY) {
+ cancelLoadingTimeout()
+ hideSwitchingIndicator()
+ loadingOverlay.visibility = View.GONE
+ loadingText.visibility = View.GONE
+ showChannelName()
+ }
+ }
+
+ override fun onPlayerError(error: PlaybackException) {
+ cancelLoadingTimeout()
+ loadingOverlay.visibility = View.GONE
+ loadingText.visibility = View.GONE
+ if (retryCount < maxRetries) {
+ retryCount++
+ showSwitchingStatus("重试 $retryCount/$maxRetries")
+ mainHandler.postDelayed({
+ player?.seekTo(0)
+ player?.prepare()
+ // 重新设定加载超时(重试也算)
+ startLoadingTimeout()
+ }, 1000L)
+ } else {
+ showSwitchingStatus("加载失败,随机切换")
+ mainHandler.postDelayed({
+ autoNextChannel()
+ }, 800L)
+ }
+ }
+ })
+ }
+ playerView.player = player
+ ChannelManager.saveCurrentIndex(this, ChannelManager.currentIndex)
+ }
+
+ private fun switchChannel(next: Boolean) {
+ val channel = if (isReverseOrder xor next) {
+ ChannelManager.prevChannel()
+ } else {
+ ChannelManager.nextChannel()
+ }
+ if (channel != null) {
+ playChannel(channel)
+ }
+ }
+
+ private fun autoNextChannel() {
+ val channels = ChannelManager.loadChannels(this)
+ if (channels.isEmpty()) return
+ if (channels.size == 1) {
+ playChannel(channels[0])
+ return
+ }
+ // 随机跳到另一个台,避免在两个坏台间无限循环
+ var nextIndex: Int
+ do {
+ nextIndex = (0 until channels.size).random()
+ } while (nextIndex == ChannelManager.currentIndex && channels.size > 1)
+
+ ChannelManager.saveCurrentIndex(this, nextIndex)
+ ChannelManager.getChannel(nextIndex)?.let { playChannel(it) }
+ }
+
+ private fun showChannelName() {
+ val channel = ChannelManager.getCurrentChannel()
+ if (channel != null) {
+ val total = ChannelManager.getChannelCount()
+ val num = ChannelManager.currentIndex + 1
+ channelNameOverlay.text = "[$num/$total] " + channel.name
+ channelNameOverlay.visibility = View.VISIBLE
+ mainHandler.removeCallbacks(hideChannelNameRunnable)
+ mainHandler.postDelayed(hideChannelNameRunnable, CHANNEL_NAME_DURATION_MS)
+ refreshToolbar()
+ }
+ }
+
+ // ======================== 网络回调 ========================
+
+ private fun registerNetworkCallback() {
+ val connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
+ val builder = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ connectivityManager.registerNetworkCallback(builder.build(), object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ val sourceUrl = ChannelManager.getSavedSourceUrl(this@MainActivity)
+ if (sourceUrl != null && ChannelManager.getChannelCount() > 0) {
+ executor.execute {
+ downloadAndUpdateSource(sourceUrl)
+ }
+ }
+ }
+ })
+ }
+
+ // ======================== 生命周期 ========================
+
+ override fun onResume() {
+ super.onResume()
+ player?.play()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ player?.pause()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ player?.release()
+ executor.shutdown()
+ mainHandler.removeCallbacksAndMessages(null)
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 07d5da9..90bde70 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,170 +1,10 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index 2b068d1..b3f5b67 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,30 +1,30 @@
-
-
-
-
-
-
-
-
+
-
\ No newline at end of file
+ android:pathData="M14,24 L94,24 Q100,24 100,30 L100,86 Q100,92 94,92 L14,92 Q8,92 8,86 L8,30 Q8,24 14,24 Z"
+ android:fillColor="#1565C0" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 86a5d97..e248d2a 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,19 +1,232 @@
-
+ android:keepScreenOn="true">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_gravity="center"
+ android:text="加载中..."
+ android:textColor="#FFFFFF"
+ android:textSize="18sp"
+ android:id="@+id/loading_text"
+ android:visibility="gone" />
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 614a0a7..a166930 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,7 +1,8 @@
-
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index c8524cd..768b058 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -2,4 +2,4 @@
#FF000000
#FFFFFFFF
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a57b6f3..6b8f7f5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,4 @@
+
- FengTv
-
\ No newline at end of file
+ FengTV
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 483eef2..a166930 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,9 +1,8 @@
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/gradle.properties b/gradle.properties
index 20e2a01..8d23b59 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,23 +1,8 @@
# 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
+org.gradle.java.home=C\:/Program Files/Java/jdk-17
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
\ No newline at end of file
+android.nonTransitiveRClass=true
+systemProp.gradle.wrapperUser=
+systemProp.gradle.wrapperPassword=
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3e8d8d0..c2c8c0f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,6 @@
[versions]
-agp = "8.4.0-rc02"
-kotlin = "1.9.0"
+agp = "8.4.0"
+kotlin = "1.9.22"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
@@ -9,6 +9,7 @@ appcompat = "1.6.1"
material = "1.10.0"
activity = "1.8.0"
constraintlayout = "2.1.4"
+media3 = "1.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -19,8 +20,10 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+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-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
-
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 25a32b3..1a2cf21 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,5 @@
-#Wed Jun 17 14:18:24 CST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
+distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.6-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
index c0b4d02..de0cf49 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,5 +1,8 @@
pluginManagement {
repositories {
+ maven { setUrl("https://maven.aliyun.com/repository/public") }
+ maven { setUrl("https://maven.aliyun.com/repository/google") }
+ maven { setUrl("https://maven.aliyun.com/repository/gradle-plugin") }
google {
content {
includeGroupByRegex("com\\.android.*")
@@ -14,6 +17,8 @@ pluginManagement {
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
+ maven { setUrl("https://maven.aliyun.com/repository/public") }
+ maven { setUrl("https://maven.aliyun.com/repository/google") }
google()
mavenCentral()
}
@@ -21,4 +26,4 @@ dependencyResolutionManagement {
rootProject.name = "FengTv"
include(":app")
-
\ No newline at end of file
+