港澳台直播 v8.3.0 源码快照(含全部历史功能)
No files matched your search
@@ -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) { }
|
||||
}
|
||||
}
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">港澳台直播</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||