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 @@ - - - - -