港澳台直播 v8.3.0 源码快照(含全部历史功能)

This commit is contained in:
Hermes Agent committed 2026-10-08 12:56:16 +08:00
commit 1d2449ec44
72 files changed
+7365

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.iptv807.tv"
compileSdk = 35
defaultConfig {
applicationId = "com.iptv807.tv"
minSdk = 21
targetSdk = 35
versionCode = 830
versionName = "8.3.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.webkit:webkit:1.9.0")
// ExoPlayer (Media3) 播放 FLV/HLS 直播流
implementation("androidx.media3:media3-exoplayer:1.3.1")
implementation("androidx.media3:media3-exoplayer-hls:1.3.1")
implementation("androidx.media3:media3-ui:1.3.1")
implementation("androidx.media3:media3-datasource:1.3.1")
}
View File
Whitespace-only changes.
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application android:name=".IptvApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.IPTVTV"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:banner="@drawable/banner"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.IPTVTV">
<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>
<activity android:name=".LoginActivity" android:exported="false" />
<activity android:name=".TrialExpiredActivity" android:exported="false" />
<activity android:name=".ShopActivity" android:exported="false" />
<receiver android:name=".BootReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<activity android:name=".SettingsActivity" android:exported="false" />
<activity android:name=".AiSettingsActivity" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,201 @@
package com.iptv807.tv
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.KeyEvent
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
/** AI 助手 —— 超保守实现:静态布局,异常完全不崩 */
class AiChatActivity : Activity() {
private lateinit var scroll: ScrollView
private lateinit var chatBox: LinearLayout
private lateinit var input: EditText
private lateinit var statusText: TextView
private val handler = Handler(Looper.getMainLooper())
private var channelsText = ""
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try { init() } catch (e: Exception) {
val r = LinearLayout(this)
r.orientation = LinearLayout.VERTICAL
val tv = TextView(this)
tv.text = "AI 加载失败(已恢复)。回按下返回重启"
tv.setTextColor(Color.RED); tv.textSize = 16f
r.addView(tv)
setContentView(r)
}
}
@SuppressLint("SetTextI18n")
private fun init() {
val root = LinearLayout(this)
root.orientation = LinearLayout.VERTICAL
root.setBackgroundColor(Color.parseColor("#12141A"))
root.setPadding(48, 40, 48, 40)
setContentView(root)
val title = TextView(this)
title.text = "🤖 AI 助手"
title.textSize = 24f; title.setTextColor(Color.WHITE); title.gravity = Gravity.CENTER
root.addView(title, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 20 })
// 检测配置
if (!AiClient.isConfigured(this)) {
val hint = TextView(this)
hint.text = "尚未配置 AI。\n请先到 设置 → AI 配置 填写 API 地址 / Key / 模型名。"
hint.textSize = 15f; hint.setTextColor(Color.LTGRAY); hint.gravity = Gravity.CENTER
root.addView(hint, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = 200 })
val go = Button(this)
go.text = "去配置 AI"; go.textSize = 16f
go.setOnClickListener {
try { startActivity(Intent(this, AiSettingsActivity::class.java)) } catch (e: Exception) { }
}
root.addView(go, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = 40 })
return
}
// 拼频道清单
try {
channelsText = ChannelData.load().take(160).joinToString("\n") { c -> "${c.num}. ${c.name} [${c.group}]" }
} catch (e: Exception) { channelsText = "" }
scroll = ScrollView(this)
scroll.setBackgroundColor(Color.parseColor("#171A20"))
chatBox = LinearLayout(this)
chatBox.orientation = LinearLayout.VERTICAL
chatBox.setPadding(24, 24, 24, 24)
scroll.addView(chatBox)
root.addView(scroll, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f
).apply { bottomMargin = 16 })
// 快捷键按钮(功能入口)
val quickActions = listOf(
"📺 当前在播什么" to "@now",
"🎯 推荐值得看" to "@rec",
"📊 我的收视报告" to "@report",
)
val row = LinearLayout(this)
row.orientation = LinearLayout.HORIZONTAL
for ((label, action) in quickActions) {
val b = Button(this)
b.text = label.take(3); b.textSize = 11f
b.setBackgroundColor(Color.parseColor("#2A2F3A"))
b.setOnClickListener {
try { ask(action, label) } catch (e: Exception) { statusText.text = "动作失败" }
}
row.addView(b, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { rightMargin = 8 })
}
root.addView(row, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = 16 })
input = EditText(this)
input.hint = "问点啥…"
input.textSize = 16f
input.setTextColor(Color.WHITE); input.setHintTextColor(Color.GRAY)
input.setBackgroundColor(Color.parseColor("#23262F"))
input.setPadding(24, 20, 24, 20)
input.setSingleLine(true)
root.addView(input, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = 16 })
statusText = TextView(this)
statusText.textSize = 13f; statusText.setTextColor(Color.LTGRAY); statusText.gravity = Gravity.CENTER
root.addView(statusText, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = 12 })
val send = Button(this)
send.text = "发送"; send.textSize = 17f
send.setOnClickListener {
try {
val q = input.text?.toString()?.trim().orEmpty()
if (q.isNotEmpty()) ask(q, null)
input.setText("")
} catch (e: Exception) { statusText.text = "发送失败" }
}
root.addView(send, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
addBubble("AI 已就绪。可以直接说话,或用上面的快捷键。", Color.parseColor("#2A3038"))
}
private fun addBubble(text: String, bg: Int) {
try {
val v = TextView(this)
v.text = text
v.textSize = 15f; v.setTextColor(Color.WHITE)
v.setBackgroundColor(bg)
v.setPadding(20, 16, 20, 16)
chatBox.addView(v, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = 12 })
scroll.post { scroll.fullScroll(android.view.View.FOCUS_DOWN) }
} catch (e: Exception) { }
}
private fun ask(action: String, label: String?) {
val q = when (action) {
"@now" -> "当前时段值得看的频道有哪些节目"
"@rec" -> "推荐我现在时期值得看的3-5个频道"
"@report" -> "请基于我的收视记录给报告"
else -> action
}
addBubble(label ?: q, Color.parseColor("#1B3A5C"))
statusText.text = "思考中…"
val ctx = this
Thread {
var reply = try {
val sys = "你是Android TV IPTV应用的AI助手。频道清单(编号. 名称 [组]):\n$channelsText\n简洁回复,直接给频道编号和名字。"
AiClient.chat(ctx, sys, q, 600)
} catch (e: Exception) { "AI 调用失败: ${e.message}" }
handler.post {
try { statusText.text = "" } catch (e: Exception) { }
try {
// SWITCH:台号 切台
if (reply.contains("SWITCH:")) {
val target = reply.substringAfter("SWITCH:").substringBefore("\n").trim()
val found = ChannelData.load().firstOrNull { ch -> ch.name.contains(target, true) }
if (found != null) {
MainActivity.playRequested = found.num
addBubble("已切到:${found.num} ${found.name}", Color.parseColor("#2A4030"))
handler.postDelayed({ try { finish() } catch (e: Exception) { } }, 800)
return@post
}
}
} catch (e: Exception) { }
addBubble(reply, Color.parseColor("#2A3038"))
}
}.start()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK) {
try { finish() } catch (e: Exception) { }
return true
}
return super.onKeyDown(keyCode, event)
}
}
private fun String?.orFallback(): String = this ?: ""
@@ -0,0 +1,62 @@
package com.iptv807.tv
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
/**
* 通用 AI 客户端 —— 兼容 OpenAI /api/chat/completions 格式
* 用户在设置里填: BASE_URL / API_KEY / MODEL(任何一个 OpenAI 兼容服务都可用,
* 如 new-api 一站式、DeepSeek、通义、Kimi、OpenAI、本地Ollama等)
*/
object AiClient {
private const val PREF = "iptv_ai"
fun baseUrl(ctx: Context) = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString("base_url", "https://ai.88531.cn/v1") ?: "https://ai.88531.cn/v1"
fun apiKey(ctx: Context) = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString("api_key", "") ?: ""
fun model(ctx: Context) = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString("model", "deepseek-v4.1-flash") ?: "deepseek-v4.1-flash"
fun isConfigured(ctx: Context): Boolean =
baseUrl(ctx).isNotEmpty() && apiKey(ctx).isNotEmpty() && model(ctx).isNotEmpty()
fun save(ctx: Context, baseUrl: String, apiKey: String, model: String) {
ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit()
.putString("base_url", baseUrl.trim().removeSuffix("/"))
.putString("api_key", apiKey.trim())
.putString("model", model.trim()).apply()
}
/** chat 请求(同步,调用方自行放子线程)返回回复文本,失败抛异常 */
fun chat(ctx: Context, systemPrompt: String, userPrompt: String, maxTokens: Int = 500): String {
val url = URL(baseUrl(ctx) + "/chat/completions")
val body = JSONObject().apply {
put("model", model(ctx))
put("max_tokens", maxTokens)
put("temperature", 0.6)
put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", systemPrompt))
.put(JSONObject().put("role", "user").put("content", userPrompt)))
}
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 10000
readTimeout = 60000
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Authorization", "Bearer ${apiKey(ctx)}")
doOutput = true
outputStream.use { it.write(body.toString().toByteArray(StandardCharsets.UTF_8)) }
}
val code = conn.responseCode
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
?.let { BufferedReader(InputStreamReader(it, StandardCharsets.UTF_8)).readText() } ?: ""
conn.disconnect()
if (code !in 200..299) throw RuntimeException("HTTP $code: ${text.take(180)}")
val j = JSONObject(text)
return j.optJSONArray("choices")?.optJSONObject(0)?.optJSONObject("message")?.optString("content") ?: ""
}
}
@@ -0,0 +1,99 @@
package com.iptv807.tv
import android.annotation.SuppressLint
import android.app.Activity
import android.graphics.Color
import android.os.Bundle
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
/** 用户自定义 AI 服务配置(OpenAI兼容格式) */
class AiSettingsActivity : Activity() {
private lateinit var baseEdit: EditText
private lateinit var keyEdit: EditText
private lateinit var modelEdit: EditText
private lateinit var statusText: TextView
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.parseColor("#12141A"))
setPadding(80, 56, 80, 56)
}
setContentView(root)
root.addView(TextView(this).apply {
text = "AI 功能配置"
textSize = 26f; setTextColor(Color.WHITE); gravity = android.view.Gravity.CENTER
setPadding(0, 0, 0, 12)
})
root.addView(TextView(this).apply {
text = "兼容 OpenAI 格式。支持:DeepSeek、通义、Kimi、OpenAI、本地Ollama、NewAPI 中转等。填你的 key 即可。"
textSize = 13f; setTextColor(Color.LTGRAY); gravity = android.view.Gravity.CENTER
setPadding(0, 0, 0, 32)
})
fun mkEdit(hint: String, v: String = "") = EditText(this).apply {
this.hint = hint
setText(v)
setSingleLine(true)
textSize = 15f
setTextColor(Color.WHITE); setHintTextColor(Color.GRAY)
setBackgroundColor(Color.parseColor("#23262F"))
setPadding(24, 20, 24, 20)
}
baseEdit = mkEdit("https://ai.88531.cn/v1(可改自己的)", AiClient.baseUrl(this))
keyEdit = mkEdit("API Key(sk-…)", AiClient.apiKey(this))
modelEdit = mkEdit("deepseek-v4.1-flash(可改)", AiClient.model(this))
listOf(baseEdit, keyEdit, modelEdit).forEach {
root.addView(it, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = 24 })
}
val saveBtn = Button(this).apply { text = "保存"; textSize = 17f }
val testBtn = Button(this).apply { text = "测试连接"; textSize = 17f }
val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
row.addView(saveBtn, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { rightMargin = 16 })
row.addView(testBtn, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
root.addView(row)
statusText = TextView(this).apply {
textSize = 14f; setTextColor(Color.LTGRAY); gravity = android.view.Gravity.CENTER
setPadding(0, 32, 0, 0)
text = "AI 功能:语音搜台 · 节目解读 · 故障诊断 · 智能推荐 · 找片 · 收视报告"
}
root.addView(statusText)
saveBtn.setOnClickListener {
if (baseEdit.text.trim().isEmpty() || keyEdit.text.trim().isEmpty() || modelEdit.text.trim().isEmpty()) {
statusText.text = "三项都要填"; return@setOnClickListener
}
AiClient.save(this, baseEdit.text.toString(), keyEdit.text.toString(), modelEdit.text.toString())
statusText.setTextColor(Color.parseColor("#4CAF50"))
statusText.text = "✓ 已保存"
}
testBtn.setOnClickListener {
if (!AiClient.isConfigured(this)) {
statusText.setTextColor(Color.RED); statusText.text = "先填写并保存三项"; return@setOnClickListener
}
statusText.setTextColor(Color.LTGRAY)
statusText.text = "测试中…"
Thread {
try {
val r = AiClient.chat(this, "你是测试助手", "回复:OK")
runOnUiThread { statusText.setTextColor(Color.parseColor("#4CAF50")); statusText.text = "✓ 连通:$r" }
} catch (e: Exception) {
runOnUiThread { statusText.setTextColor(Color.RED); statusText.text = "✗ ${e.message}" }
}
}.start()
}
}
}
@@ -0,0 +1,16 @@
package com.iptv807.tv
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 i = Intent(context, LoginActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(i)
}
}
}
@@ -0,0 +1,38 @@
package com.iptv807.tv
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
/** CardAuth 公开API的轻量封装 */
object CardAuthApi {
private const val BASE = "http://app.88531.cn:8881"
private const val API_KEY = "ak_563f4768d5d411d381e58bc3f76ba447"
fun get(path: String): JSONObject = request("GET", path, null)
fun post(path: String, body: JSONObject): JSONObject = request("POST", path, body.toString())
private fun request(method: String, path: String, body: String?): JSONObject {
val conn = (URL(BASE + path).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 8000; readTimeout = 10000
setRequestProperty("Content-Type", "application/json")
setRequestProperty("X-API-Key", API_KEY)
if (body != null && method == "POST") {
doOutput = true
outputStream.use { it.write(body.toByteArray(StandardCharsets.UTF_8)) }
}
}
val code = conn.responseCode
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
?.let { BufferedReader(InputStreamReader(it, StandardCharsets.UTF_8)).readText() } ?: ""
conn.disconnect()
if (text.trimStart().startsWith("<")) {
throw RuntimeException("服务端返回了HTML(路径错误/网关拦截),HTTP $code")
}
return JSONObject(text)
}
}
@@ -0,0 +1,75 @@
package com.iptv807.tv
/**
* 港澳台专项清单(55台)—— 3大子分组(香港 / 澳门 / 台湾)
* 存 tid+id,播放Url每次实时解密(token 每次都换)
*/
object ChannelData {
data class Channel(val num: Int, val name: String, val group: String, val tid: String, val id: String)
data class Group(val name: String, val startNum: Int, val count: Int)
fun load(): List<Channel> = listOf(
Channel(1, "HOY TV", "🌏 香港", "tv", "43"),
Channel(2, "HOY国际财经台", "🌏 香港", "tv", "41"),
Channel(3, "HOY资讯台", "🌏 香港", "tv", "42"),
Channel(4, "NOW新闻台", "🌏 香港", "tv", "36"),
Channel(5, "NOW财经台", "🌏 香港", "tv", "37"),
Channel(6, "TVB Plus", "🌏 香港", "tv", "26"),
Channel(7, "TVB无线新闻台", "🌏 香港", "tv", "25"),
Channel(8, "TVB明珠台", "🌏 香港", "tv", "27"),
Channel(9, "TVB翡翠台", "🌏 香港", "tv", "24"),
Channel(10, "ViuTV", "🌏 香港", "tv", "38"),
Channel(11, "ViuTV6", "🌏 香港", "tv", "39"),
Channel(12, "凤凰卫视中文台", "🌏 香港", "tv", "20"),
Channel(13, "凤凰卫视资讯台", "🌏 香港", "tv", "21"),
Channel(14, "凤凰卫视香港台", "🌏 香港", "tv", "22"),
Channel(15, "港台电视31", "🌏 香港", "tv", "45"),
Channel(16, "港台电视32", "🌏 香港", "gt", "27"),
Channel(17, "香港卫视HKS", "🌏 香港", "gt", "30"),
Channel(18, "香港有线18台", "🌏 香港", "gt", "12"),
Channel(19, "香港有线18台(备)", "🌏 香港", "gt", "13"),
Channel(20, "香港有线新闻台", "🌏 香港", "tv", "29"),
Channel(21, "澳視澳門", "🌏 澳门", "gt", "38"),
Channel(22, "澳视综艺", "🌏 澳门", "gt", "40"),
Channel(23, "澳视资讯", "🌏 澳门", "gt", "37"),
Channel(24, "澳門Macau", "🌏 澳门", "gt", "39"),
Channel(25, "澳門卫视", "🌏 澳门", "gt", "36"),
Channel(26, "澳门莲花", "🌏 澳门", "gt", "35"),
Channel(27, "DAZN 1", "🌏 台湾", "tv", "80"),
Channel(28, "DAZN 2", "🌏 台湾", "tv", "81"),
Channel(29, "ELEVEN 1(DAZN 1)", "🌏 台湾", "ty", "24"),
Channel(30, "ELEVEN 2(DAZN 2)", "🌏 台湾", "ty", "25"),
Channel(31, "TVBS HD", "🌏 台湾", "tv", "65"),
Channel(32, "TVBS新闻台", "🌏 台湾", "tv", "63"),
Channel(33, "三立新闻台", "🌏 台湾", "tv", "54"),
Channel(34, "东森新闻台", "🌏 台湾", "tv", "50"),
Channel(35, "东森财经新闻台", "🌏 台湾", "gt", "45"),
Channel(36, "东森超视", "🌏 台湾", "tv", "98"),
Channel(37, "中天新闻台", "🌏 台湾", "tv", "49"),
Channel(38, "中视HD", "🌏 台湾", "tv", "68"),
Channel(39, "中视新闻台", "🌏 台湾", "tv", "52"),
Channel(40, "华视HD", "🌏 台湾", "tv", "71"),
Channel(41, "华视新闻台", "🌏 台湾", "tv", "57"),
Channel(42, "台视HD", "🌏 台湾", "tv", "69"),
Channel(43, "台视新闻台", "🌏 台湾", "tv", "53"),
Channel(44, "寰宇新闻台", "🌏 台湾", "tv", "59"),
Channel(45, "寰宇新闻台湾台", "🌏 台湾", "tv", "60"),
Channel(46, "民视HD", "🌏 台湾", "tv", "70"),
Channel(47, "民视新闻台", "🌏 台湾", "tv", "56"),
Channel(48, "爱尔达体育1台", "🌏 台湾", "tv", "83"),
Channel(49, "爱尔达体育2台", "🌏 台湾", "tv", "84"),
Channel(50, "緯來體育台", "🌏 台湾", "tv", "77"),
Channel(51, "纬来日本台", "🌏 台湾", "tv", "78"),
Channel(52, "纬来育乐台", "🌏 台湾", "tv", "79"),
Channel(53, "镜电视新闻台", "🌏 台湾", "tv", "58"),
Channel(54, "非凡新闻", "🌏 台湾", "tv", "62"),
Channel(55, "华丽翡翠台", "🌏 其他", "tv", "28")
)
fun groups(): List<Group> = listOf(
Group("🌏 香港", 1, 20),
Group("🌏 澳门", 21, 6),
Group("🌏 台湾", 27, 28),
Group("🌏 其他", 55, 1)
)
}
@@ -0,0 +1,36 @@
package com.iptv807.tv
import android.content.Context
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** 收视历史(供 AI 推荐/报告用) */
object HistoryRecorder {
private fun prefs(ctx: Context) = ctx.getSharedPreferences("iptv_history", Context.MODE_PRIVATE)
private val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.CHINA)
fun log(ctx: Context, chanNum: Int, name: String) {
val now = fmt.format(Date())
val p = prefs(ctx)
var count = p.getInt("count_${chanNum}", 0)
count++
p.edit().putInt("count_${chanNum}", count)
.putString("last_" + chanNum, "$now $name").apply()
// 只保留最近 90 days(清理老数据)
if (p.all.size > 200) {
val sorted = p.all.keys.sorted()
p.edit().remove(sorted.take(50).toString()).apply()
}
}
fun topChannels(ctx: Context, n: Int = 5): List<Pair<Int, String>> {
val p = prefs(ctx)
return p.all.entries
.filter { it.key.startsWith("count_") }
.sortedByDescending { it.value as Int }
.take(n)
.map { it.key.removePrefix("count_").toInt() }
.mapNotNull { num -> ChannelData.load().find { it.num == num }?.let { num to it.name } }
}
}
@@ -0,0 +1,19 @@
package com.iptv807.tv
import android.app.Application
/** 应用级 crash 防护:把异常写标记后照常崩溃(不吞什),下次启动 reset license 缓存 */
class IptvApp : Application() {
override fun onCreate() {
super.onCreate()
val default = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { t, e ->
try {
getSharedPreferences("iptv_crash", MODE_PRIVATE)
.edit().putBoolean("crashed", true)
.putString("msg", "${e.javaClass.simpleName}: ${e.message?.take(200)}").commit()
} catch (ex: Exception) { }
default?.uncaughtException(t, e)
}
}
}
@@ -0,0 +1,116 @@
package com.iptv807.tv
import android.content.Context
import android.os.Build
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
/**
* CardAuth 授权客户端(app.88531.cn:8881)
*
* 协议(已实测):
* POST /api/public/verify
* Header: X-API-Key: ak_...(项目API密钥)
* Body: {"card_key":"...","machine_id":"TV-XXXX","device_info":"AndroidTV"}
* 未用卡 → 自动激活绑定;已绑 → 校验machine_id一致 + 未过期
* 响应 data: {valid, type, duration_days, expire_time, is_permanent, remaining_days}
* 免费用户:无卡/无效卡 → 每天最多1小时(本地计时 + 服务器日期同步)
*/
object LicenseClient {
private const val BASE = "http://app.88531.cn:8881"
private const val API_KEY = "ak_563f4768d5d411d381e58bc3f76ba447"
data class Result(
val ok: Boolean, // 通过授权(VIP)
val message: String,
val expireTime: String? = null,
val isPermanent: Boolean = false,
val remainingDays: Int? = null,
val trial: Boolean = false // 走免费1小时
)
/** 卡密验证/激活 */
fun verify(cardKey: String, machineId: String): Result {
return try {
val body = JSONObject().apply {
put("card_key", cardKey)
put("machine_id", machineId)
put("device_info", "AndroidTV ${Build.MODEL}")
}
val r = post("/api/public/verify", body)
val data = r.optJSONObject("data") ?: JSONObject()
when {
r.optInt("code") == 200 && data.optBoolean("valid") ->
Result(true, data.optString("message", "授权有效"),
data.optString("expire_time", ""), data.optBoolean("is_permanent"),
if (data.has("remaining_days") && !data.isNull("remaining_days")) data.optInt("remaining_days") else null)
else -> Result(false, data.optString("message", r.optString("message", "授权无效")).ifEmpty { "卡密无效" })
}
} catch (e: Exception) {
Result(false, "网络错误: ${e.message ?: e.javaClass.simpleName}")
}
}
private fun post(path: String, body: JSONObject): JSONObject {
val url = URL(BASE + path)
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 8000
readTimeout = 10000
setRequestProperty("Content-Type", "application/json")
setRequestProperty("X-API-Key", API_KEY)
doOutput = true
outputStream.use { it.write(body.toString().toByteArray(StandardCharsets.UTF_8)) }
}
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val text = stream?.let { BufferedReader(InputStreamReader(it, StandardCharsets.UTF_8)).readText() } ?: ""
conn.disconnect()
return JSONObject(text)
}
/** 稳定机器码(安装期持久化+硬件融合) */
fun machineCode(ctx: Context): String {
val prefs = ctx.getSharedPreferences("iptv_license", Context.MODE_PRIVATE)
prefs.getString("machine_code", null)?.let { return it }
val raw = listOf(Build.BOARD, Build.BRAND, Build.DEVICE, Build.MODEL, Build.PRODUCT).joinToString("|")
val md = MessageDigest.getInstance("SHA-256")
val code = "TV-" + md.digest(raw.toByteArray(StandardCharsets.UTF_8))
.joinToString("") { "%02X".format(it) }.take(12)
prefs.edit().putString("machine_code", code).apply()
return code
}
// ===== 免费时长控制(每天1小时)=====
const val FREE_DAILY_MS = 60 * 60 * 1000L
/** 今天已用免费毫秒数 */
fun freeUsedTodayMs(ctx: Context): Long {
val prefs = ctx.getSharedPreferences("iptv_license", Context.MODE_PRIVATE)
val today = todayKey()
if (prefs.getString("free_date", "") != today) return 0
return prefs.getLong("free_used_ms", 0)
}
fun addFreeUsedMs(ctx: Context, delta: Long) {
val prefs = ctx.getSharedPreferences("iptv_license", Context.MODE_PRIVATE)
val today = todayKey()
val used = if (prefs.getString("free_date", "") == today) prefs.getLong("free_used_ms", 0) else 0
prefs.edit().putString("free_date", today).putLong("free_used_ms", used + delta).apply()
}
fun freeRemainingMs(ctx: Context): Long =
(FREE_DAILY_MS - freeUsedTodayMs(ctx)).coerceAtLeast(0)
/** 日期键北京时间 */
private fun todayKey(): String {
val f = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.CHINA)
f.timeZone = java.util.TimeZone.getTimeZone("GMT+8")
return f.format(java.util.Date())
}
}
@@ -0,0 +1,143 @@
package com.iptv807.tv
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.view.KeyEvent
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
/**
* CardAuth 授权登录页 —— 应用第一屏:
* 有效卡本地缓存 → 直接进播放;否则显示卡密输入
* CardAuth 无用户体系:一卡一机绑定,验证即在服务端注册到期时间
*/
class LoginActivity : Activity() {
private lateinit var cardEdit: EditText
private lateinit var statusText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
// crashGuard: 若上次有崩溃标记,先清before检查
getSharedPreferences("iptv_crash", MODE_PRIVATE).let {
if (it.getBoolean("crashed", false)) {
it.edit().putBoolean("crashed", false).apply()
// 清理license cache以防被污染
getSharedPreferences("iptv_license", MODE_PRIVATE).edit().remove("token").remove("card_key").apply()
}
}
} catch (e: Exception) { }
val prefs = getSharedPreferences("iptv_license", MODE_PRIVATE)
val machine = LicenseClient.machineCode(this)
// 本地已授权缓存 → 快速放行(在线重验在主界面做,避免卡启动)
if (prefs.getBoolean("is_vip", false)) {
startMain(); return
}
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = android.view.Gravity.CENTER
setBackgroundColor(Color.parseColor("#12141A"))
setPadding(96, 64, 96, 64)
}
setContentView(root)
root.addView(TextView(this).apply {
text = "港澳台直播"
textSize = 30f; setTextColor(Color.WHITE)
gravity = android.view.Gravity.CENTER; setPadding(0, 0, 0, 16)
})
root.addView(TextView(this).apply {
text = "免费用户每天可看 1 小时 · 输入卡密解锁全天观看"
textSize = 15f; setTextColor(Color.LTGRAY)
gravity = android.view.Gravity.CENTER; setPadding(0, 0, 0, 48)
})
cardEdit = EditText(this).apply {
hint = "卡密(如 8914-XXXX-XXXX)"
setSingleLine(true)
textSize = 18f
setTextColor(Color.WHITE); setHintTextColor(Color.GRAY)
setBackgroundColor(Color.parseColor("#23262F"))
setPadding(24, 24, 24, 24)
imeOptions = EditorInfo.IME_ACTION_DONE
}
root.addView(cardEdit, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 32 })
val btn = Button(this).apply {
text = "激活 / 验证"; textSize = 19f
setOnClickListener { doVerify() }
}
root.addView(btn, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
statusText = TextView(this).apply {
textSize = 14f; setTextColor(Color.LTGRAY)
gravity = android.view.Gravity.CENTER; setPadding(0, 40, 0, 0)
text = "已有卡密可直接激活;或购买套餐自动绑定本机"
}
root.addView(statusText)
val shopBtn = Button(this).apply {
text = "购买套餐(支付宝/微信)"; textSize = 15f
setBackgroundColor(Color.parseColor("#1B6EF3"))
setOnClickListener { startActivity(android.content.Intent(this@LoginActivity, ShopActivity::class.java)) }
}
root.addView(shopBtn, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = 24 })
val freeBtn = Button(this).apply {
text = "先看一会(免费)"; textSize = 14f
setBackgroundColor(Color.parseColor("#2A3344"))
setOnClickListener {
getSharedPreferences("iptv_license", MODE_PRIVATE).edit().putBoolean("is_vip", false).apply()
startMain()
}
}
root.addView(freeBtn, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = 24 })
cardEdit.setOnEditorActionListener { _,_,_ -> doVerify(); true }
}
private fun doVerify() {
val card = cardEdit.text.toString().trim()
if (card.isEmpty()) { statusText.text = "请输入卡密"; return }
statusText.text = "验证中…"
val machine = LicenseClient.machineCode(this)
Thread {
val r = LicenseClient.verify(card, machine)
runOnUiThread {
if (r.ok) {
getSharedPreferences("iptv_license", MODE_PRIVATE).edit()
.putBoolean("is_vip", true)
.putString("card_key", card)
.putString("vip_expire", r.expireTime)
.putBoolean("is_permanent", r.isPermanent)
.apply()
Toast.makeText(this, "✓ ${r.message}${if (!r.isPermanent && r.expireTime != null) "\n到期: ${r.expireTime}" else ""}", Toast.LENGTH_LONG).show()
startMain()
} else {
statusText.text = r.message
}
}
}.start()
}
private fun startMain() {
startActivity(Intent(this, MainActivity::class.java))
finish()
}
}
@@ -0,0 +1,742 @@
package com.iptv807.tv
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.CountDownTimer
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.AbsListView
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.PlayerView
/**
* 港澳台直播 v7.0 —— 纯原生播放(无网页兜底)
*
* · App 本地解密获取新鲜播放源 → ExoPlayer 直接播放(带 Referer/302)
* (纯原生播放,无网页兜底)
*/
class MainActivity : Activity() {
private val channels: List<ChannelData.Channel> = ChannelData.load()
private val groups: List<ChannelData.Group> = ChannelData.groups()
private val favGroup = ChannelData.Group("❤ 收藏", 0, 0)
private var player: ExoPlayer? = null
private lateinit var playerView: PlayerView
private lateinit var statusText: TextView
private lateinit var trialText: TextView
private lateinit var digitText: TextView
private lateinit var loadingText: TextView
private lateinit var loadingMask: FrameLayout
private lateinit var root: FrameLayout
private var panelLayout: LinearLayout? = null
private var groupList: ListView? = null
private var channelList: ListView? = null
private var curGroupPos = 0
private var currentChannel = -1
private var currentLine = 0
private var mode = MODE_NATIVE
private val handler = Handler(Looper.getMainLooper())
private val hidePanelTask = Runnable { hidePanel() }
private val uiTick = Runnable { onUiTick() }
private var loadStart = 0L
private val loadTick = object : Runnable {
override fun run() {
if (loadingMask.visibility != View.VISIBLE) return
val s = ((System.currentTimeMillis() - loadStart) / 1000 + 1).toInt()
loadingText.text = "正在加载 $s 秒,精彩继续…"
handler.postDelayed(this, 1000)
}
}
private var digitBuf = ""
private val digitTask = object : CountDownTimer(2000, 2000) {
override fun onTick(m: Long) {}
override fun onFinish() { if (digitBuf.isNotEmpty()) { commitDigit(); digitBuf = "" } }
}
private var isVip = false
private var lastPlayError = ""
private var lastSelfTest = ""
private var failureReport = ""
private var wasStopped = false
private var resumeBtn: TextView? = null
// 连续滑动两次退出
private var lastSwipeTs = 0L
private var downX = 0f
private var downY = 0f
private var downT = 0L
private var exitHint: TextView? = null
companion object {
const val MODE_NATIVE = 0
@JvmStatic var playRequested: Int = -1
}
@SuppressLint("SetJavaScriptEnabled", "SetTextI18n", "ClickableViewAccessibility")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try { init(savedInstanceState) } catch (e: Exception) {
// 保守恢复:显示错误不闪退
val r = FrameLayout(this)
r.setBackgroundColor(Color.BLACK)
val tv = TextView(this)
tv.text = "启动异常已恢复 (${e.javaClass.simpleName})\n请按返回键重启"
tv.setTextColor(Color.WHITE); tv.textSize = 18f; tv.gravity = Gravity.CENTER
r.addView(tv)
setContentView(r)
}
}
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
private fun init(savedInstanceState: Bundle?) {
hideSystemUi()
root = FrameLayout(this)
setContentView(root)
// 1) 原生播放层
playerView = PlayerView(this).apply { useController = false }
root.addView(playerView, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
// 3) 左上状态
statusText = TextView(this).apply {
textSize = 18f; setTextColor(Color.WHITE)
setShadowLayer(4f, 2f, 2f, Color.BLACK)
setPadding(32, 24, 32, 24)
}
root.addView(statusText, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
// 4) 右上:免费时长 + 模式
val rightCol = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
trialText = TextView(this).apply {
textSize = 14f; setTextColor(Color.WHITE)
setBackgroundColor(0x88000000u.toInt())
setPadding(20, 10, 20, 10)
}
rightCol.addView(trialText)
val rcLP = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.END)
root.addView(rightCol, rcLP)
// 5) 数字键大字
digitText = TextView(this).apply {
textSize = 56f; setTextColor(Color.WHITE)
setShadowLayer(6f, 3f, 3f, Color.BLACK)
setPadding(24, 64, 48, 24)
visibility = View.GONE
}
root.addView(digitText, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.END))
// 6) 加载罩
loadingMask = FrameLayout(this).apply { setBackgroundColor(0xB2000000u.toInt()); visibility = View.GONE }
loadingText = TextView(this).apply {
textSize = 28f; setTextColor(Color.WHITE); gravity = Gravity.CENTER
setShadowLayer(6f, 3f, 3f, Color.BLACK)
}
loadingMask.addView(loadingText, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
root.addView(loadingMask, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
// 8) 「继续播放」按钮(从桌面回来画面卡住时点它重连)
resumeBtn = TextView(this).apply {
text = "▶ 继续播放"
textSize = 22f
setTextColor(Color.WHITE)
setBackgroundColor(0xE61B6EF3u.toInt())
setPadding(48, 24, 48, 24)
gravity = Gravity.CENTER
isFocusable = true
visibility = View.GONE
setOnClickListener { resumePlayback() }
}
root.addView(resumeBtn, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
// 7) ExoPlayer 初始化
val loadControl = androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs(1500, 12000, 500, 1000)
.build()
player = ExoPlayer.Builder(this).setLoadControl(loadControl).setMediaSourceFactory(
DefaultMediaSourceFactory(
DefaultDataSource.Factory(this,
DefaultHttpDataSource.Factory()
.setUserAgent("Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36")
.setDefaultRequestProperties(mapOf(
"Referer" to "https://m.iptv807.com/",
"Origin" to "https://m.iptv807.com"
))
.setAllowCrossProtocolRedirects(true)
.setConnectTimeoutMs(8000)
.setReadTimeoutMs(12000)
)
)).build()
playerView.player = player
player?.addListener(object : Player.Listener {
override fun onPlayerError(err: androidx.media3.common.PlaybackException) {
lastPlayError = "播放错误 code=${err.errorCode}: ${err.message?.take(60) ?: ""}"
autoSwitchLine()
}
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_READY -> { setMode(MODE_NATIVE); hideLoading() }
Player.STATE_BUFFERING -> { /* 缓冲中,保持计时继续 */ }
}
}
override fun onIsPlayingChanged(p: Boolean) {
if (p) { hideLoading(); resumeBtn?.visibility = View.GONE }
}
})
// 触屏/遥控 OK 弹面板
playerView.setOnClickListener { if (!isPanelVisible()) showPanel() else hidePanel() }
SourceResolver.init(this)
// 播放期间保持屏幕常亮
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
checkLicense()
if (savedInstanceState == null && channels.isNotEmpty()) {
val defIdx = channels.indexOfFirst { it.name.contains("TVB翡翠台") }.let { if (it >= 0) it else 0 }
playChannel(defIdx)
// 启动后台预取默认频道所在分组,之后切台秒开
val g = channels.getOrNull(defIdx)?.group
val gi = ChannelData.groups().indexOfFirst { it.name == g }
if (gi >= 0) handler.postDelayed({ prefetchGroup(gi) }, 2500)
}
}
private fun setMode(m: Int) { mode = m }
/** 启动自检:解析源 + 实测第一线路连通性,结果在状态栏显示 10 秒 */
private fun runSelfTest(idx: Int) {
val ch = channels.getOrNull(idx) ?: return
Thread {
val t0 = System.currentTimeMillis()
val urls = try { SourceResolver.resolve(ch.tid, ch.id) } catch (e: Exception) { null }
val resolveMs = System.currentTimeMillis() - t0
val info = StringBuilder()
if (urls.isNullOrEmpty()) {
info.append("自检: 解析失败(${SourceResolver.lastError})")
} else {
info.append("自检: 解析${urls.size}线/${resolveMs}ms")
// 实测第一条能否连上
info.append(" | ").append(SourceResolver.verifyChain(ch.tid, ch.id))
}
runOnUiThread {
statusText.text = info.toString()
handler.postDelayed({
// 10 秒后恢复正常显示
val c = channels.getOrNull(currentChannel)
if (c != null) statusText.text = "▶ ${c.num} ${c.name}"
}, 10000)
}
}.start()
}
private fun freeRemainingMs(): Long = LicenseClient.freeRemainingMs(this)
private fun checkLicense() {
val prefs = getSharedPreferences("iptv_license", MODE_PRIVATE)
val card = prefs.getString("card_key", "") ?: ""
Thread {
val r = if (card.isNotEmpty()) LicenseClient.verify(card, LicenseClient.machineCode(this)) else LicenseClient.Result(false, "免费")
runOnUiThread {
isVip = r.ok
prefs.edit().putBoolean("is_vip", r.ok).apply()
if (r.ok) { trialText.visibility = View.GONE; trialText.text = "VIP" } else enterFreeMode()
}
}.start()
}
private fun enterFreeMode() {
trialText.visibility = View.VISIBLE
handler.removeCallbacks(uiTick)
handler.post(uiTick)
}
private fun onUiTick() {
if (isVip) return
// 每秒累计一次免费用量,这样右上角倒计时才会真正走动
if (player?.isPlaying == true) LicenseClient.addFreeUsedMs(this, 1000)
val r = freeRemainingMs()
trialText.visibility = View.VISIBLE
trialText.text = if (r <= 0) "⏳ 今日免費已用完"
else "⏳ 今日免費 ${r / 60000}分${((r % 60000) / 1000).toString().padStart(2, '0')}秒"
handler.postDelayed(uiTick, 1000)
}
private fun showTrialExpired() {
player?.pause()
trialText.visibility = View.GONE
statusText.text = "今日免费时长已用完"
startActivity(Intent(this, TrialExpiredActivity::class.java))
finish()
}
private fun isPanelVisible(): Boolean = panelLayout?.visibility == View.VISIBLE
private fun panelGroups(): List<ChannelData.Group> =
if (favSet().isNotEmpty()) listOf(favGroup) + groups else groups
private fun favSet(): MutableSet<String> =
getSharedPreferences("iptv_fav", MODE_PRIVATE).getStringSet("favs", HashSet<String>())!!.toMutableSet()
private fun toggleFav(ch: ChannelData.Channel) {
val s = favSet()
val n = ch.num.toString()
if (n in s) s.remove(n) else s.add(n)
getSharedPreferences("iptv_fav", MODE_PRIVATE).edit().putStringSet("favs", s).apply()
}
private fun channelsOfGroup(gpos: Int): List<ChannelData.Channel> {
val g = panelGroups()[gpos]
return if (g.name == favGroup.name) {
favSet().mapNotNull { n -> channels.find { it.num == n.toInt() } }.sortedBy { it.num }
} else channels.filter { it.group == g.name }
}
@SuppressLint("ClickableViewAccessibility")
private fun showPanel() {
if (panelLayout == null) {
panelLayout = buildPanel()
root.addView(panelLayout, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
}
panelLayout?.visibility = View.VISIBLE
val gpos = panelGroups().indexOfFirst { g ->
val num = channels.getOrNull(currentChannel)?.num
if (g.name == favGroup.name) num?.toString() in favSet()
else g.name == channels.getOrNull(currentChannel)?.group
}
if (gpos >= 0) { curGroupPos = gpos; groupList?.setSelection(gpos + 1); updateChannelListOfGroup(gpos) }
resetHideTimer()
}
@SuppressLint("ClickableViewAccessibility")
private fun buildPanel(): LinearLayout = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setBackgroundColor(0xE8141418u.toInt())
setOnClickListener { hidePanel() }
groupList = ListView(this@MainActivity).apply {
setBackgroundColor(Color.TRANSPARENT)
adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_list_item_1,
listOf("⚙ 设置") + panelGroups().map { "${it.name} (${it.count})" })
onItemClickListener = AdapterView.OnItemClickListener { _, _, pos, _ ->
if (pos == 0) {
startActivity(Intent(this@MainActivity, SettingsActivity::class.java))
hidePanel(); return@OnItemClickListener
}
curGroupPos = pos - 1
groupList?.setSelection(pos)
updateChannelListOfGroup(curGroupPos)
}
setPadding(8, 24, 8, 24)
setOnTouchListener { v, ev ->
if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) resetHideTimer()
v.onTouchEvent(ev)
}
}
addView(groupList, LinearLayout.LayoutParams(
(resources.displayMetrics.widthPixels * 0.2f).toInt(), ViewGroup.LayoutParams.MATCH_PARENT))
channelList = ListView(this@MainActivity).apply {
setBackgroundColor(Color.TRANSPARENT)
onItemClickListener = AdapterView.OnItemClickListener { _, _, pos, _ ->
val list = channelsOfGroup(curGroupPos)
if (pos < list.size) { playChannel(channels.indexOf(list[pos])); hidePanel() }
}
setPadding(8, 24, 8, 24)
setOnTouchListener { v, ev ->
if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) resetHideTimer()
v.onTouchEvent(ev)
}
setOnScrollListener(object : AbsListView.OnScrollListener {
override fun onScrollStateChanged(v: AbsListView, s: Int) { resetHideTimer() }
override fun onScroll(v: AbsListView, a: Int, b: Int, c: Int) { resetHideTimer() }
})
}
addView(channelList, LinearLayout.LayoutParams(
(resources.displayMetrics.widthPixels * 0.38f).toInt(), ViewGroup.LayoutParams.MATCH_PARENT))
}
private fun updateChannelListOfGroup(pos: Int) {
prefetchGroup(pos)
val list = channelsOfGroup(pos)
channelList?.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1,
list.map { "%3d %s".format(it.num, it.name) })
}
private fun hidePanel() { handler.removeCallbacks(hidePanelTask); panelLayout?.visibility = View.GONE }
/** 后台预取某分组所有频道的源,让切台变秒切(每次运行只做一轮) */
private var prefetchedGroups = HashSet<Int>()
private fun prefetchGroup(gpos: Int) {
if (prefetchedGroups.contains(gpos)) return
prefetchedGroups.add(gpos)
val list = channelsOfGroup(gpos)
Thread {
for (ch in list) {
try { SourceResolver.resolveLines(ch.tid, ch.id) } catch (e: Exception) { }
try { Thread.sleep(250) } catch (ie: InterruptedException) { }
}
}.start()
}
private fun resetHideTimer() {
handler.removeCallbacks(hidePanelTask)
handler.postDelayed(hidePanelTask, 5000)
}
private fun hideLoading() { loadingMask.visibility = View.GONE; handler.removeCallbacks(loadTick) }
/** 开始一次新的换台计时(只在切台时调用) */
private fun beginLoading() {
loadStart = System.currentTimeMillis()
loadingText.text = "正在加载 1 秒,精彩继续…"
loadingMask.visibility = View.VISIBLE
handler.removeCallbacks(loadTick)
handler.postDelayed(loadTick, 1000)
}
private fun playChannel(idx: Int, line: Int = 0) {
if (idx >= channels.size) return
val isSameChannel = (idx == currentChannel)
currentChannel = idx; currentLine = line
val ch = channels[idx]
if (!isSameChannel || loadingMask.visibility != View.VISIBLE) {
beginLoading() // 只有换台才重置计时;同台换线路时继续计时
}
Thread {
val urls = try { SourceResolver.resolve(ch.tid, ch.id) } catch (e: Exception) { null }
if (urls.isNullOrEmpty()) {
// 自动重试 3 次(间隔 4 秒),期间给友好提示
var got: List<String>? = null
for (r in 1..3) {
runOnUiThread { statusText.text = "网络波动,正在重试 ($r/3)…"; loadingText.text = "正在加载,精彩继续…" }
Thread.sleep(4000)
got = try { SourceResolver.resolveLines(ch.tid, ch.id) } catch (e: Exception) { null }
if (!got.isNullOrEmpty()) break
}
if (got.isNullOrEmpty()) {
runOnUiThread {
statusText.text = "⚠ ${ch.num} ${ch.name} 源解析失败\n解析: ${SourceResolver.lastError}"
failureReport = "解析失败 ${SourceResolver.lastError}"
hideLoading()
}
return@Thread
}
val u2 = got!!
runOnUiThread { playUrl(u2[0], ch) }
return@Thread
}
// 不预探测(那是切台慢的主因):直接播对应线路,失败由 onPlayerError 自动换下一条
var chosen = line % urls.size
val probe = StringBuilder("skip")
val tok = Regex("token=([^&]+)").find(urls[0])?.groupValues?.get(1)?.take(14) ?: "?"
failureReport = "探测: $probe\ntoken: $tok"
lastSelfTest = failureReport
if (chosen < 0) {
// 用的是缓存线路且全失效 → 强制刷新一次再试
runOnUiThread { statusText.text = "线路失效,正在刷新源…" }
Thread.sleep(2500)
val fresh = try { SourceResolver.resolveFresh(ch.tid, ch.id) } catch (e: Exception) { null }
if (!fresh.isNullOrEmpty() && fresh != urls) {
for (i in fresh.indices) {
val st = probeLine(fresh[i])
if (st == "200" || st == "206" || st == "302") { chosen = i; break }
}
if (chosen >= 0) {
runOnUiThread { playUrl(fresh[chosen], ch) }
return@Thread
}
}
}
runOnUiThread {
if (chosen < 0) {
statusText.text = "⚠ ${ch.num} ${ch.name} 源站拒绝(全部线路)\n$failureReport"
hideLoading()
} else {
playUrl(urls[chosen], ch)
}
}
}.start()
}
/** 轻量探测一条线路:跟 302 后取 HTTP 状态码 */
private fun probeLine(url: String): String = try {
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
requestMethod = "GET"; connectTimeout = 4000; readTimeout = 4000
setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36")
setRequestProperty("Referer", "https://m.iptv807.com/")
setRequestProperty("Origin", "https://m.iptv807.com")
setRequestProperty("Range", "bytes=0-1")
instanceFollowRedirects = true
}
val code = conn.responseCode
runCatching { conn.inputStream?.close() }
conn.disconnect()
code.toString()
} catch (e: Exception) { "E:${e.javaClass.simpleName}" }
private fun playUrl(url: String, ch: ChannelData.Channel) {
val isFlv = url.contains("type=.flv") || url.endsWith(".flv")
val item = MediaItem.Builder().setUri(Uri.parse(url))
.setMimeType(if (isFlv) MimeTypes.VIDEO_FLV else MimeTypes.APPLICATION_M3U8)
.build()
player?.setMediaItem(item)
player?.prepare()
player?.playWhenReady = true
statusText.text = "… ${ch.num} ${ch.name}"
HistoryRecorder.log(this, ch.num, ch.name)
}
@SuppressLint("SetTextI18n")
private fun autoSwitchLine() {
val ch = channels.getOrNull(currentChannel) ?: return
currentLine++
if (currentLine > 12) {
val vn = try { packageManager.getPackageInfo(packageName, 0).versionName } catch (e: Exception) { "?" }
statusText.text = "⚠ ${ch.num} ${ch.name} 全部线路失败\n$lastPlayError\n解析: ${SourceResolver.lastError}\n$failureReport\n版本: v$vn"
hideLoading()
return
}
playChannel(currentChannel, currentLine)
}
private fun onDigit(d: Int) {
digitBuf += d.toString()
digitText.text = digitBuf
digitText.visibility = View.VISIBLE
val t = digitBuf.toIntOrNull() ?: return
val idx = channels.indexOfFirst { it.num == t }
if (idx >= 0) {
playChannel(idx); digitBuf = ""; digitText.visibility = View.GONE; digitTask.cancel()
} else { digitTask.cancel(); digitTask.start() }
}
private fun commitDigit() {
val t = digitBuf.toIntOrNull() ?: return
val idx = channels.indexOfFirst { it.num == t }
if (idx >= 0) playChannel(idx)
digitText.visibility = View.GONE
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// 「继续播放」按钮可见时,遥控 OK/确认 直接触发
if (resumeBtn?.visibility == View.VISIBLE &&
(keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER ||
keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER || keyCode == KeyEvent.KEYCODE_SPACE)) {
resumePlayback(); return true
}
if (isPanelVisible()) resetHideTimer()
if (keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9) { onDigit(keyCode - KeyEvent.KEYCODE_0); return true }
if (keyCode == KeyEvent.KEYCODE_NUMPAD_0) { onDigit(0); return true }
if (keyCode in KeyEvent.KEYCODE_NUMPAD_1..KeyEvent.KEYCODE_NUMPAD_9) { onDigit(keyCode - KeyEvent.KEYCODE_NUMPAD_0); return true }
when (keyCode) {
KeyEvent.KEYCODE_MENU -> {
if (isPanelVisible()) {
val list = channelsOfGroup(curGroupPos)
val pos = channelList?.checkedItemPosition ?: -1
if (pos in 0 until list.size) {
toggleFav(list[pos])
updateChannelListOfGroup(curGroupPos)
Toast.makeText(this, if (favSet().contains(list[pos].num.toString())) "★ 已收藏 ${list[pos].name}" else "☆ 已取消 ${list[pos].name}", Toast.LENGTH_SHORT).show()
}
return true
} else { autoSwitchLine(); return true }
}
KeyEvent.KEYCODE_CHANNEL_UP -> { playChannel((currentChannel - 1 + channels.size) % channels.size); return true }
KeyEvent.KEYCODE_CHANNEL_DOWN -> { playChannel((currentChannel + 1) % channels.size); return true }
KeyEvent.KEYCODE_DPAD_LEFT -> {
if (isPanelVisible()) {
curGroupPos = (curGroupPos - 1 + groups.size) % groups.size
groupList?.setSelection(curGroupPos + 1); updateChannelListOfGroup(curGroupPos)
return true
}
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
if (isPanelVisible()) {
curGroupPos = (curGroupPos + 1) % groups.size
groupList?.setSelection(curGroupPos + 1); updateChannelListOfGroup(curGroupPos)
return true
}
}
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN -> {
if (isPanelVisible()) return super.onKeyDown(keyCode, event)
playChannel((currentChannel + (if (keyCode == KeyEvent.KEYCODE_DPAD_UP) -1 else 1) + channels.size) % channels.size); return true
}
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
if (isPanelVisible()) {
val list = channelsOfGroup(curGroupPos)
val pos = channelList?.checkedItemPosition ?: -1
if (pos >= 0 && pos < list.size) { playChannel(channels.indexOf(list[pos])); hidePanel(); return true }
}
showPanel(); return true
}
KeyEvent.KEYCODE_BACK -> { if (isPanelVisible()) { hidePanel(); return true } }
}
return super.onKeyDown(keyCode, event)
}
/** 全局手势:连续滑动两次 → 完全退出 App */
override fun dispatchTouchEvent(ev: android.view.MotionEvent?): Boolean {
try {
if (ev != null) {
when (ev.actionMasked) {
android.view.MotionEvent.ACTION_DOWN -> {
downX = ev.rawX; downY = ev.rawY; downT = System.currentTimeMillis()
}
android.view.MotionEvent.ACTION_UP -> {
val dx = ev.rawX - downX
val dy = ev.rawY - downY
val dist = kotlin.math.hypot(dx.toDouble(), dy.toDouble()).toFloat()
val dt = System.currentTimeMillis() - downT
// 一次有效滑动:位移够大、时间够短
if (dist > 120f && dt < 800) {
val now = System.currentTimeMillis()
if (now - lastSwipeTs < 1500) { exitApp(); return true }
lastSwipeTs = now
showExitHint()
}
}
}
}
} catch (e: Exception) { }
return super.dispatchTouchEvent(ev)
}
/** 第一次滑动后提示"再滑一次退出" */
private fun showExitHint() {
runOnUiThread {
if (exitHint == null) {
exitHint = TextView(this).apply {
textSize = 18f; setTextColor(Color.WHITE)
setBackgroundColor(0xB3000000u.toInt())
setPadding(40, 20, 40, 20); gravity = Gravity.CENTER
}
(window.decorView as android.view.ViewGroup).addView(exitHint,
android.view.ViewGroup.LayoutParams(
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT).also {
(it as android.widget.FrameLayout.LayoutParams).gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
it.bottomMargin = 80
})
}
exitHint?.text = "再滑动一次退出应用"
exitHint?.visibility = View.VISIBLE
handler.removeCallbacks(hideExitHintTask)
handler.postDelayed(hideExitHintTask, 2000)
}
}
private val hideExitHintTask = Runnable { exitHint?.visibility = View.GONE }
/** 退出前先弹窗确认 */
private fun exitApp() {
runOnUiThread {
try {
exitHint?.visibility = View.GONE
android.app.AlertDialog.Builder(this)
.setTitle("退出应用")
.setMessage("确定要退出「港澳台直播」吗?")
.setPositiveButton("退出") { _, _ -> doExit() }
.setNegativeButton("取消", null)
.setCancelable(true)
.show()
} catch (e: Exception) {
doExit() // 弹窗异常时直接退出,别卡住
}
}
}
/** 真正完全退出 App */
private fun doExit() {
try { handler.removeCallbacksAndMessages(null) } catch (e: Exception) { }
try { player?.release() } catch (e: Exception) { }
try { finishAndRemoveTask() } catch (e: Exception) { finishAffinity() }
// 彻底结束进程(电视盒子上保证真的退出)
handler.postDelayed({ android.os.Process.killProcess(android.os.Process.myPid()) }, 250)
}
override fun onResume() {
super.onResume()
// 从桌面/设置返回:先尝试续播,失败则显示「继续播放」按钮
if (wasStopped) {
wasStopped = false
try { player?.play() } catch (e: Exception) { }
handler.postDelayed({
// 直播流暂停后常常定格无法续播 → 给用户一个明确的按钮
if (player?.isPlaying != true) resumeBtn?.visibility = View.VISIBLE
else resumeBtn?.visibility = View.GONE
}, 1800)
}
if (playRequested > 0) {
val idx = channels.indexOfFirst { it.num == playRequested }
playRequested = -1
if (idx >= 0) playChannel(idx)
}
}
/** 点「继续播放」→ 重新连接当前频道(直播流最稳的恢复方式) */
private fun resumePlayback() {
resumeBtn?.visibility = View.GONE
val idx = if (currentChannel in channels.indices) currentChannel else 0
try { player?.play() } catch (e: Exception) { }
// 等 1.2 秒看是否已恢复,没恢复就重连
handler.postDelayed({
if (player?.isPlaying != true) playChannel(idx)
}, 1200)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) hideSystemUi()
}
private fun hideSystemUi() {
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
}
override fun onStop() {
wasStopped = true
super.onStop()
player?.pause()
}
override fun onDestroy() {
digitTask.cancel()
handler.removeCallbacksAndMessages(null)
playerView.player = null
player?.release()
super.onDestroy()
}
}
@@ -0,0 +1,152 @@
package com.iptv807.tv
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.Switch
import android.widget.TextView
/**
* 设置页:
* - 版本号显示
* - 开机自启动开关(BOOT_COMPLETED receiver)
* - 会员状态显示(已是会员显示有效期;非会员显示成为会员按钮→跳购买)
*/
class SettingsActivity : Activity() {
private lateinit var statusText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
// crashGuard: 若上次有崩溃标记,先清before检查
getSharedPreferences("iptv_crash", MODE_PRIVATE).let {
if (it.getBoolean("crashed", false)) {
it.edit().putBoolean("crashed", false).apply()
// 清理license cache以防被污染
getSharedPreferences("iptv_license", MODE_PRIVATE).edit().remove("token").remove("card_key").apply()
}
}
} catch (e: Exception) { }
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.parseColor("#12141A"))
setPadding(96, 64, 96, 64)
}
setContentView(root)
root.addView(TextView(this).apply {
text = "设置"
textSize = 28f; setTextColor(Color.WHITE)
gravity = Gravity.CENTER; setPadding(0, 0, 0, 48)
})
// ===== 版本信息 =====
val versionName = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: "1.0"
} catch (e: Exception) { "1.0" }
val prefs = getSharedPreferences("iptv_license", MODE_PRIVATE)
val isVip = prefs.getBoolean("is_vip", false)
val expire = prefs.getString("vip_expire", "")
val permanent = prefs.getBoolean("is_permanent", false)
val cardKey = prefs.getString("card_key", "")
root.addView(TextView(this).apply {
text = "版本信息"
textSize = 15f; setTextColor(Color.parseColor("#7AA7D9"))
setPadding(0, 0, 0, 8)
})
root.addView(TextView(this).apply {
text = "App 版本:$versionName\n授权系统:CardAuth(app.88531.cn)\n机器码:${LicenseClient.machineCode(this@SettingsActivity)}"
textSize = 15f; setTextColor(Color.WHITE)
setLineSpacing(0f, 1.15f)
setBackgroundColor(Color.parseColor("#1C2028"))
setPadding(24, 20, 24, 20)
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 40 })
// ===== 开机自启动 =====
root.addView(TextView(this).apply {
text = "通用设置"
textSize = 15f; setTextColor(Color.parseColor("#7AA7D9"))
setPadding(0, 0, 0, 8)
})
val bootSwitch = Switch(this).apply {
text = "开机自动启动"; textSize = 17f
setTextColor(Color.WHITE)
isChecked = isBootReceiverEnabled()
val pad = (24 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, pad / 2)
setBackgroundColor(Color.parseColor("#1C2028"))
}
bootSwitch.setOnCheckedChangeListener { _, checked ->
val pm = packageManager
val comp = ComponentName(this, BootReceiver::class.java)
pm.setComponentEnabledSetting(
comp,
if (checked) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
}
root.addView(bootSwitch, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 40 })
// ===== 会员状态 =====
root.addView(TextView(this).apply {
text = "会员状态"
textSize = 15f; setTextColor(Color.parseColor("#7AA7D9"))
setPadding(0, 0, 0, 8)
})
statusText = TextView(this).apply {
textSize = 15f; setLineSpacing(0f, 1.15f)
setTextColor(Color.WHITE)
setBackgroundColor(Color.parseColor("#1C2028"))
setPadding(24, 20, 24, 20)
text = if (isVip) {
"✓ 已是会员\n${if (permanent) "永久有效" else "有效期至:$expire"}${if (cardKey?.isNotEmpty() == true) "\n卡号:$cardKey" else ""}"
} else {
"当前为免费用户(每天 1 小时)\n尚未成为会员"
}
}
root.addView(statusText, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 24 })
if (isVip) {
root.addView(TextView(this).apply {
text = "✓ 已经是会员"
textSize = 18f; setTextColor(Color.parseColor("#4CAF50"))
gravity = Gravity.CENTER; setPadding(0, 24, 0, 24)
})
} else {
val btn = Button(this).apply {
text = "支付成为会员(月/季/年)"
textSize = 17f
setBackgroundColor(Color.parseColor("#1B6EF3"))
setOnClickListener { startActivity(Intent(this@SettingsActivity, ShopActivity::class.java)) }
}
root.addView(btn, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
}
}
private fun isBootReceiverEnabled(): Boolean =
packageManager.getComponentEnabledSetting(ComponentName(this, BootReceiver::class.java)) ==
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK) { finish(); return true }
return super.onKeyDown(keyCode, event)
}
}
@@ -0,0 +1,246 @@
package com.iptv807.tv
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.TextView
import org.json.JSONArray
/**
* 套餐商店 —— App 内直接购买
* 流程: GET card-types → 用户选定套餐+支付方式 → POST /api/public/orders
* (contact_info=MACHINE:TV-xxx) → 拉起浏览器 pay_url 扫码
* → 轮询 /api/public/orders/query?order_no= → paid → 查询卡号 → 自动verify → 进主界面
*/
class ShopActivity : Activity() {
private val plans = mutableListOf<Plan>()
data class Plan(val id: Int, val name: String, val price: String, val days: Int)
private lateinit var list: ListView
private lateinit var statusText: TextView
private var payType = "alipay"
private var orderNo = ""
private var machineId = ""
private val handler = Handler(Looper.getMainLooper())
private var pollTask: Runnable? = null
private var pollCount = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
// crashGuard: 若上次有崩溃标记,先清before检查
getSharedPreferences("iptv_crash", MODE_PRIVATE).let {
if (it.getBoolean("crashed", false)) {
it.edit().putBoolean("crashed", false).apply()
// 清理license cache以防被污染
getSharedPreferences("iptv_license", MODE_PRIVATE).edit().remove("token").remove("card_key").apply()
}
}
} catch (e: Exception) { }
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.parseColor("#12141A"))
setPadding(64, 48, 64, 48)
}
setContentView(root)
root.gravity = Gravity.CENTER
machineId = LicenseClient.machineCode(this)
root.addView(TextView(this).apply {
text = "IPTV · 选择套餐"
textSize = 26f; setTextColor(Color.WHITE)
gravity = Gravity.CENTER; setPadding(0, 0, 0, 12)
})
root.addView(TextView(this).apply {
text = "支付成功自动激活本机(无需卡密)"
textSize = 14f; setTextColor(Color.LTGRAY)
gravity = Gravity.CENTER; setPadding(0, 0, 0, 32)
})
list = ListView(this).apply {
setBackgroundColor(Color.parseColor("#1C1F26"))
}
root.addView(list, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f
).apply { bottomMargin = 24 })
statusText = TextView(this).apply {
textSize = 14f; setTextColor(Color.LTGRAY)
gravity = Gravity.CENTER; setPadding(0, 0, 0, 24)
}
root.addView(statusText)
val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
val alipay = Button(this).apply { text = "支付宝"; textSize = 15f }
val wxpay = Button(this).apply { text = "微信"; textSize = 15f }
fun sel(b: Button, t: String) {
val active = payType == t
b.setBackgroundColor(if (active) Color.parseColor("#1B6EF3") else Color.parseColor("#2A2F3A"))
}
alipay.setOnClickListener { payType = "alipay"; sel(alipay, "alipay"); sel(wxpay, "wxpay") }
wxpay.setOnClickListener { payType = "wxpay"; sel(alipay, "alipay"); sel(wxpay, "wxpay") }
sel(alipay, "alipay"); sel(wxpay, "wxpay")
row.addView(alipay, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { rightMargin = 16 })
row.addView(wxpay, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
root.addView(row)
list.onItemClickListener = AdapterView.OnItemClickListener { _, _, pos, _ ->
if (pos < plans.size) createOrder(plans[pos])
}
loadPlans()
}
@SuppressLint("SetTextI18n")
private fun loadPlans() {
statusText.text = "加载套餐…"
Thread {
try {
val r = CardAuthApi.get("/api/public/projects/2/card-types")
val arr = r.optJSONArray("data") ?: JSONArray()
plans.clear()
for (i in 0 until arr.length()) {
val p = arr.getJSONObject(i)
// 接口不返回 status 字段时视为启用(默认 1),只有显式 status=0 才跳过
val st = if (p.has("status")) p.optInt("status", 1) else 1
if (st == 1) {
val days = p.optInt("duration_days", 0)
plans.add(Plan(p.optInt("id"), p.optString("name"), p.optString("price"), days))
}
}
if (plans.isEmpty()) {
runOnUiThread { statusText.text = "暂无可用套餐(请稍后重试)" }
}
runOnUiThread {
plans.sortBy { it.days }
list.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, plans.map {
val d = it.days
"📱 ${it.name} ¥${it.price} ${if (d >= 999) "永久" else "${d}天"}"
})
statusText.text = "选择套餐后按 OK 购买"
}
} catch (e: Exception) {
runOnUiThread { statusText.text = "加载失败: ${e.javaClass.simpleName}: ${e.message?.take(150)}" }
}
}.start()
}
private fun createOrder(plan: Plan) {
statusText.text = "创建订单…"
Thread {
try {
val body = org.json.JSONObject().apply {
put("project_id", 2)
put("card_type_id", plan.id)
put("pay_type", payType)
put("contact_info", "MACHINE:$machineId")
}
val r = CardAuthApi.post("/api/public/orders", body)
val d = r.optJSONObject("data")
val url = d?.optString("pay_url") ?: ""
runOnUiThread {
if (url.isNotEmpty()) {
orderNo = d!!.optString("order_no", "")
statusText.text = "订单已创建,正在打开支付页…支付成功后自动激活"
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
startPolling()
} else {
statusText.text = "下单失败: ${r.optString("message")}"
}
}
} catch (e: Exception) {
runOnUiThread { statusText.text = "下单错误: ${e.message}" }
}
}.start()
}
private fun startPolling() {
pollCount = 0
pollTask = object : Runnable {
override fun run() {
pollCount++
if (pollCount > 60) { // 最多3分钟
handler.post { statusText.text = "等待支付超时,可重新下单" }
return
}
Thread {
var paid = false
var cardKey = ""
try {
val r = CardAuthApi.get("/api/public/orders/query?order_no=$orderNo")
val d = r.optJSONObject("data") ?: org.json.JSONObject()
paid = d.optString("status") == "paid"
cardKey = d.optString("card_key", "")
} catch (e: Exception) { }
runOnUiThread {
if (paid) {
statusText.text = "✓ 支付成功!正在激活本机…"
if (cardKey.isNotEmpty()) activateAndEnter(cardKey)
else { // 兜底: 直接用verify免卡路径
handler.postDelayed({
getSharedPreferences("iptv_license", MODE_PRIVATE)
.edit().putBoolean("is_vip", true).apply()
enterMain()
}, 500)
}
} else {
statusText.text = "等待支付…(${pollCount * 3}秒)"
handler.postDelayed(this, 3000)
}
}
}.start()
}
}
handler.postDelayed(pollTask!!, 3000)
}
private fun activateAndEnter(cardKey: String) {
Thread {
val r = LicenseClient.verify(cardKey, machineId)
runOnUiThread {
getSharedPreferences("iptv_license", MODE_PRIVATE).edit()
.putBoolean("is_vip", r.ok)
.putString("card_key", if (r.ok) cardKey else "")
.putString("vip_expire", r.expireTime)
.putBoolean("is_permanent", r.isPermanent)
.apply()
if (r.ok) {
statusText.text = "✓ 激活成功,开始播放"
enterMain()
} else {
statusText.text = "激活校验失败: ${r.message}(可直接输入卡密重试)"
}
}
}.start()
}
private fun enterMain() {
startActivity(Intent(this, MainActivity::class.java))
finish()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && pollTask != null) {
// 支付轮询中返回 → 取消轮询
handler.removeCallbacks(pollTask!!); pollTask = null
}
return super.onKeyDown(keyCode, event)
}
}
@@ -0,0 +1,266 @@
package com.iptv807.tv
import android.util.Base64
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
/**
* iptv807 播放源本地解密 —— App 内直接解密,完全不经过 VPS
*
* 解密步骤(Python mirror 端到端 verified):
* 1. GET https://m.iptv807.com/?act=play&tid=X&id=Y (UA=Android mobile, Referer=m.iptv807.com)
* 2. 找 <script>.split("") 混淆块, 逐语句 (quote-aware ; 分割)
* 3. evalConcat: 双引号对 - 其后紧跟 .split("").reverse().join("") → 取反向值,并跳过整个 suffix(含 "" 内嵌引号不再被当作独立literal)
* 4. 从 assignments 找:
* - 首个 32hex = xKey (加密 key)
* - 另一个 32hex = oldToken
* - 含 %XX 的长串 = newToken
* 5. xorConst = html 里最早的 "16hex" 字面量
* 6. decrypt option:
* reversed → base64 → XOR(key=xKey+xorConst) → base64 → eat token 替换 → xKey 剥离 → URLDecode UTF-8
*/
object SourceResolver {
@Volatile private var cache: MutableMap<String, Cached>? = null
private data class Cached(val lines: List<String>, val ts: Long)
private const val TTL = 8 * 60 * 1000L
private const val BASE = "https://m.iptv807.com/"
private const val UA = "Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36"
@Volatile var lastError: String = ""
/** 磁盘兜底缓存(App 重启后仍可用上次成功的线路) */
private var appCtx: android.content.Context? = null
fun init(ctx: android.content.Context) { appCtx = ctx.applicationContext }
private fun saveDisk(key: String, lines: List<String>) {
runCatching {
appCtx?.getSharedPreferences("iptv_lines", android.content.Context.MODE_PRIVATE)?.edit()
?.putString(key, org.json.JSONArray(lines).toString())
?.putLong("ts_$key", System.currentTimeMillis())
?.apply()
}
}
private fun loadDisk(key: String): List<String>? = runCatching {
val j = appCtx?.getSharedPreferences("iptv_lines", android.content.Context.MODE_PRIVATE)?.getString(key, null)
?: return null
val arr = org.json.JSONArray(j)
(0 until arr.length()).map { arr.getString(it) }.takeIf { it.isNotEmpty() }
}.getOrNull()
/** 兼容之前的 method signature */
fun resolve(tid: String, id: String): List<String>? = resolveLines(tid, id)
/** 强制丢弃缓存重新抓源(用于"缓存线路全失效"时) */
fun resolveFresh(tid: String, id: String): List<String>? {
cache?.remove("$tid-$id")
return resolveLines(tid, id)
}
fun resolveLines(tid: String, id: String): List<String>? {
val key = "$tid-$id"
cache?.get(key)?.let { if (System.currentTimeMillis() - it.ts < TTL) return it.lines }
synchronized(this) {
// double-check
cache?.get(key)?.let { if (System.currentTimeMillis() - it.ts < TTL) return it.lines }
try {
val url = "$BASE?act=play&tid=$tid&id=$id"
val html = httpGet(url, BASE)
// 1) script blocks - find mix
val blocks = Regex("<script>([\\s\\S]*?)</script>").findAll(html).map { it.groupValues[1] }.toList()
val mixBlock = blocks.firstOrNull { it.contains(".split(\"\")") } ?: run {
lastError = "no-mix-block"; return null
}
// 2) quote-aware split by ;
val stmts = StringBuilder()
var inStr = false
for (c in mixBlock) {
when {
c == '"' -> { inStr = !inStr; stmts.append(c) }
c == ';' && !inStr -> { stmts.append('\n'); }
else -> stmts.append(c)
}
}
val statements = stmts.toString().split('\n').map { it.trim() }.filter { it.isNotEmpty() }
// 3) extensions
val REV = ".split(\"\").reverse().join(\"\")"
fun evalConcat(s: String): String = buildString {
var i = 0
while (i < s.length) {
if (s[i] == '"') {
var j = i + 1; var lit = ""
while (j < s.length && s[j] != '"') { lit += s[j]; j++ }
if (j >= s.length) { append(lit); i = s.length; break }
val tail = s.substring((j + 1).coerceAtMost(s.length))
if (tail.startsWith(REV)) {
append(lit.reversed())
i = j + 1 + REV.length
} else {
append(lit)
i = j + 1
}
while (i < s.length && (s[i] == '+' || s[i] == ' ')) i++
} else i++
}
}
// 关键:rhs 原样交给 evalConcat(不要剥前导引号/尾部括号,那会破坏字面量配对)
val assign = LinkedHashMap<String, String>()
for (st in statements) {
if (st.isEmpty()) continue
val mVar = Regex("^var\\s+(\\w+)\\s*=\\s*(.*)$").find(st)
if (mVar != null) {
val name = mVar.groupValues[1]
val rhs = mVar.groupValues[2].trim()
assign[name] = when {
rhs.isEmpty() || rhs == "\"\"" -> ""
Regex("^\"[a-f0-9]{32}\"$").matches(rhs) -> rhs.substring(1, rhs.length - 1)
else -> evalConcat(rhs)
}
continue
}
val mCopy = Regex("^(\\w+)\\s*=\\s*(\\w+)$").find(st)
if (mCopy != null) {
assign[mCopy.groupValues[1]] = assign[mCopy.groupValues[2]] ?: ""
continue
}
val mHex = Regex("^(\\w+)\\s*=\\s*\"([a-f0-9]{32})\"$").find(st)
if (mHex != null) { assign[mHex.groupValues[1]] = mHex.groupValues[2]; continue }
}
val vals = assign.values.filter { it.isNotEmpty() }
val hex32 = vals.filter { Regex("^[a-f0-9]{32}$").matches(it) }
if (hex32.isEmpty()) { lastError = "no-hex32"; return null }
val xKey = hex32[0]
// ★★ 核心修复2:混淆块里常有 "xx = yy" 复制语句 → hex32[1] 可能 == xKey,
// 那样 replace("token=$oldToken") 就匹配不到 → URL 保留过期旧 token → 全部 404。
// 必须取"第一个不等于 xKey"的 32hex 才是真正的 oldToken。
val oldToken = hex32.firstOrNull { it != xKey } ?: xKey
val newToken = vals.firstOrNull {
it.contains(Regex("%[0-9A-Fa-f]{2}")) &&
!Regex("^[a-f0-9]{32}$").matches(it) &&
!Regex("^[a-f0-9]{16}$").matches(it) &&
it != xKey
} ?: run { lastError = "no-newToken"; return null }
val xorConst = Regex("\"([a-f0-9]{16})\"").findAll(html).map { it.groupValues[1] }.firstOrNull() ?: run {
lastError = "no-xorConst"; return null
}
// 4) decrypt options
fun decrypt(encrypted: String): String? = try {
val rev = encrypted.reversed()
val b1 = String(Base64.decode(rev, Base64.DEFAULT), charset("ISO-8859-1"))
val key = xKey + xorConst
val sb = StringBuilder()
for (i in b1.indices) {
sb.append(((b1[i].toInt() and 0xFF) xor (key[i % key.length].toInt() and 0xFF)).toChar())
}
val b2raw = sb.toString()
val b2 = String(Base64.decode(b2raw, Base64.DEFAULT), charset("ISO-8859-1"))
var u = b2.replace("token=$oldToken", "token=$newToken")
u = u.replace(xKey, "")
// 兜底:若 token= 后仍是纯32位hex(旧token没替换成功),强制换成新 token
u = Regex("token=[a-f0-9]{32}").replace(u, "token=$newToken")
// ★★ 核心修复:b2 里 token 已是 %2B 编码形态,绝对不能再 URLDecoder(会把 %2B 解成 '+' 被服务端当空格 → 404)
u
} catch (e: Exception) { null }
val lines = Regex("<option value=\"([^\"]+)\"[^>]*>", RegexOption.IGNORE_CASE)
.findAll(html)
.mapNotNull { decrypt(it.groupValues[1]) }
.toList()
if (lines.isEmpty()) { lastError = "decode-empty"; return null }
if (cache == null) cache = ConcurrentHashMap()
cache!![key] = Cached(lines, System.currentTimeMillis())
saveDisk(key, lines)
lastError = "ok ${lines.size} lines"
return lines
} catch (e: Exception) {
// 网络波动时:用该频道最近一次成功的线路兜底(即使已过期)
cache?.get(key)?.let { c ->
lastError = "网络波动,用上次缓存"
return c.lines
}
loadDisk(key)?.let { d ->
lastError = "网络波动,用磁盘缓存"
return d
}
lastError = "exception: ${e.javaClass.simpleName}: ${e.message?.take(120)}"
return null
}
}
}
/** 验完整链:先 resolver,再对 line[0] HEAD拉流确认 */
fun verifyChain(tid: String, id: String): String {
val lines = resolveLines(tid, id) ?: return "resolve 错: $lastError"
val u = lines.firstOrNull() ?: return "no lines"
// HEAD / small byte read to verify FLV
return try {
val conn = (URL(u).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 8000
readTimeout = 10000
setRequestProperty("User-Agent", UA)
setRequestProperty("Referer", BASE)
setRequestProperty("Range", "bytes=0-63")
setRequestProperty("Origin", "https://m.iptv807.com")
setInstanceFollowRedirects(true)
}
val code = conn.responseCode
val hdr = conn.getHeaderField("Location") ?: ""
val magic = runCatching {
val inn = conn.inputStream
val bs = ByteArray(3); var n = 0
while (n < 3) { val v = inn.read(); if (v < 0) break; bs[n++] = v.toByte() }
String(bs)
}.getOrElse { "" }
conn.disconnect()
when {
code == 302 && hdr.startsWith("https://t") -> "ok 302"
magic.startsWith("FLV") -> "ok direct FLV"
else -> "direct code=$code magic=$magic"
}
} catch (e: Exception) { "stream err: ${e.message}" }
}
private fun httpGet(urlStr: String, referer: String?): String {
var lastEx: Exception? = null
for (attempt in 0 until 3) {
try {
val conn = (URL(urlStr).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 15000
readTimeout = 20000
setRequestProperty("User-Agent", UA)
setRequestProperty("Accept", "text/html,*/*")
setRequestProperty("Connection", "close")
if (referer != null) setRequestProperty("Referer", referer)
}
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val text = try {
stream?.let { BufferedReader(InputStreamReader(it, StandardCharsets.UTF_8)).readText() } ?: ""
} finally { runCatching { stream?.close(); conn.disconnect() } }
if (code !in 200..299) throw RuntimeException("HTTP $code")
if (text.isNotEmpty()) return text
lastEx = RuntimeException("empty body")
} catch (e: Exception) {
lastEx = e
}
try { Thread.sleep(700L * (attempt + 1)) } catch (ie: InterruptedException) {}
}
throw (lastEx ?: RuntimeException("unknown"))
}
}
@@ -0,0 +1,75 @@
package com.iptv807.tv
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
/**
* 免费时长用完 → 引导购买页
* 显示卡密商城地址(CardAuth /#/shop),用户点遥控OK打开购买页
*/
class TrialExpiredActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
// crashGuard: 若上次有崩溃标记,先清before检查
getSharedPreferences("iptv_crash", MODE_PRIVATE).let {
if (it.getBoolean("crashed", false)) {
it.edit().putBoolean("crashed", false).apply()
// 清理license cache以防被污染
getSharedPreferences("iptv_license", MODE_PRIVATE).edit().remove("token").remove("card_key").apply()
}
}
} catch (e: Exception) { }
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = android.view.Gravity.CENTER
setBackgroundColor(0xFF12141Au.toInt())
setPadding(96, 64, 96, 64)
}
setContentView(root)
root.addView(TextView(this).apply {
text = "⏰ 今日免费时长已用完"
textSize = 30f; setTextColor(0xFFFFFFFFu.toInt())
gravity = android.view.Gravity.CENTER; setPadding(0, 0, 0, 24)
})
root.addView(TextView(this).apply {
text = "免费用户每天可观看 1 小时\n购买卡密后立即解锁不限时长观看"
textSize = 17f; setTextColor(0xFFBBBBBBu.toInt())
gravity = android.view.Gravity.CENTER; setPadding(0, 0, 0, 56)
})
root.addView(Button(this).apply {
text = "购买套餐(OK键)"
textSize = 19f
setOnClickListener { startActivity(android.content.Intent(this@TrialExpiredActivity, ShopActivity::class.java)); finish() }
})
root.addView(TextView(this).apply {
text = "或直接访问:http://app.88531.cn:8881/#/shop"
textSize = 13f; setTextColor(0xFF888888u.toInt())
gravity = android.view.Gravity.CENTER; setPadding(0, 32, 0, 0)
})
root.addView(TextView(this).apply {
text = "\n明天再来可继续免费观看 1 小时"
textSize = 14f; setTextColor(0xFF7799AAu.toInt())
gravity = android.view.Gravity.CENTER; setPadding(0, 24, 0, 0)
})
}
private fun openShop() {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("http://app.88531.cn:8881/#/shop")))
} catch (e: Exception) { }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

@@ -0,0 +1,5 @@
<?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="#1B6EF3" android:pathData="M0,0h108v108h-108z"/>
</vector>
@@ -0,0 +1,9 @@
<?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">
<group android:scaleX="0.55" android:scaleY="0.55" android:translateX="24.3" android:translateY="24.3">
<path android:fillColor="#FFFFFF" android:pathData="M32,28h44l10,12v40a4,4 0 0,1 -4,4H26a4,4 0 0,1 -4,-4V40z"/>
<path android:fillColor="#1B6EF3" android:pathData="M38,50h32v22h-32z"/>
<path android:fillColor="#FFD600" android:pathData="M54,56l8,5l-8,5z"/>
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_bg"/>
<foreground android:drawable="@mipmap/ic_launcher_fg"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">港澳台直播</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<resources>
<style name="Theme.IPTVTV" parent="android:Theme.Material.NoActionBar.Fullscreen">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:colorPrimary">#1B6EF3</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>