一些修改提交
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pid": 38368,
|
||||
"version": "0.9.7",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-cb339f1c8a0989e3",
|
||||
"startedAt": 1781679324951
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"topic_20260617-065519_fbde6a25cff2683a": 1781679319697
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"topic_20260617-065519_fbde6a25cff2683a": "auto"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"topic_20260617-065519_fbde6a25cff2683a": "我要给电视盒子做一个非常简单的APP…"
|
||||
}
|
||||
@@ -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,6 +45,9 @@ 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)
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -11,16 +17,32 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FengTv"
|
||||
android:banner="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="false"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package cn.flyzxf.FengTv
|
||||
|
||||
data class ChannelInfo(
|
||||
val name: String,
|
||||
val url: String
|
||||
)
|
||||
@@ -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<ChannelInfo> = mutableListOf()
|
||||
var currentIndex: Int = 0
|
||||
private set
|
||||
|
||||
fun loadChannels(context: Context): List<ChannelInfo> {
|
||||
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<String>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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 已销毁
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== 控件绑定 ========================
|
||||
|
||||
private fun setupControls() {
|
||||
// 点击画面切换工具栏
|
||||
findViewById<View>(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<String>()
|
||||
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<String>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,170 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- 纯深蓝色背景 -->
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:fillColor="#0D47A1"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<!-- 背景色(深蓝圆角方块) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
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" />
|
||||
<!-- 屏幕高光(内部梯形) -->
|
||||
<path
|
||||
android:pathData="M15,28 L93,28 L93,76 L15,76 Z"
|
||||
android:fillColor="#1976D2" />
|
||||
<!-- 屏幕浅色区域 -->
|
||||
<path
|
||||
android:pathData="M18,32 L90,32 Q91,32 91,33 L91,74 Q91,75 90,75 L18,75 Q17,75 17,74 L17,33 Q17,32 18,32 Z"
|
||||
android:fillColor="#42A5F5" />
|
||||
<!-- 播放按钮(白色三角) -->
|
||||
<path
|
||||
android:pathData="M42,42 L42,66 L68,54 Z"
|
||||
android:fillColor="#FFFFFF" />
|
||||
<!-- 底座 -->
|
||||
<path
|
||||
android:pathData="M44,92 L64,92 L64,98 L44,98 Z"
|
||||
android:fillColor="#1565C0" />
|
||||
<!-- 底座阴影 -->
|
||||
<path
|
||||
android:pathData="M42,98 L66,98 L66,100 L42,100 Z"
|
||||
android:fillColor="#0D47A1" />
|
||||
</vector>
|
||||
@@ -1,19 +1,232 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
android:keepScreenOn="true">
|
||||
|
||||
<!-- 视频播放器(隐藏默认控制器) -->
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/player_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
app:use_controller="false" />
|
||||
|
||||
<!-- 右上角频道名 -->
|
||||
<TextView
|
||||
android:id="@+id/channel_name_overlay"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:background="#88000000"
|
||||
android:padding="12dp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- 左上角切换指示(频道序号 + 状态) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/switching_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/switching_channel_num"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FFFF00"
|
||||
android:textSize="56sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/switching_channel_total"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:paddingTop="20dp"
|
||||
android:textColor="#FFFF00"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/switching_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:paddingTop="18dp"
|
||||
android:textColor="#FFCC00"
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 加载中遮罩 -->
|
||||
<View
|
||||
android:id="@+id/loading_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FF000000"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hello World!"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
android:layout_gravity="center"
|
||||
android:text="加载中..."
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="18sp"
|
||||
android:id="@+id/loading_text"
|
||||
android:visibility="gone" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<!-- ============ 频道列表覆盖层 ============ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/channel_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#CC000000"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:background="#88000000"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="20dp"
|
||||
android:text="频道列表"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/channel_panel_close"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:padding="12dp"
|
||||
android:text="✕ 关闭"
|
||||
android:textColor="#FFAAAA"
|
||||
android:textSize="16sp" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<ListView
|
||||
android:id="@+id/channel_list_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:background="#00000000"
|
||||
android:cacheColorHint="#00000000"
|
||||
android:divider="#33FFFFFF"
|
||||
android:dividerHeight="1dp"
|
||||
android:scrollbars="vertical" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ============ 底部工具栏 ============ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bottom_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="#CC000000"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- 上一台 -->
|
||||
<ImageButton
|
||||
android:id="@+id/btn_prev_channel"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:src="@android:drawable/ic_media_previous"
|
||||
android:contentDescription="上一台" />
|
||||
|
||||
<!-- 下一台 -->
|
||||
<ImageButton
|
||||
android:id="@+id/btn_next_channel"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:src="@android:drawable/ic_media_next"
|
||||
android:contentDescription="下一台" />
|
||||
|
||||
<!-- 间隔 -->
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<!-- 频道指示器 -->
|
||||
<TextView
|
||||
android:id="@+id/toolbar_channel_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:gravity="center"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<!-- 换台反转 -->
|
||||
<ImageButton
|
||||
android:id="@+id/btn_toggle_reverse"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:src="@android:drawable/ic_menu_sort_by_size"
|
||||
android:contentDescription="换台反转" />
|
||||
|
||||
<!-- 频道列表 -->
|
||||
<ImageButton
|
||||
android:id="@+id/btn_show_list"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:src="@android:drawable/ic_menu_agenda"
|
||||
android:contentDescription="频道列表" />
|
||||
|
||||
<!-- 设置 -->
|
||||
<ImageButton
|
||||
android:id="@+id/btn_settings"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:src="@android:drawable/ic_menu_manage"
|
||||
android:contentDescription="设置" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.FengTv" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.FengTv" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,3 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">FengTv</string>
|
||||
<string name="app_name">FengTV</string>
|
||||
</resources>
|
||||
@@ -1,9 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.FengTv" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.FengTv" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.FengTv" parent="Base.Theme.FengTv" />
|
||||
</resources>
|
||||
+3
-18
@@ -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
|
||||
systemProp.gradle.wrapperUser=
|
||||
systemProp.gradle.wrapperPassword=
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user