初始化项目

This commit is contained in:
BBIT-Kai
2026-05-25 15:27:47 +08:00
commit ec8289d0f5
64 changed files with 3635 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+67
View File
@@ -0,0 +1,67 @@
package com.bbitcn.f8.pad
import android.app.Application
import android.content.Context
import android.provider.Settings
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.ui.Modifier
import com.bbitcn.f8.pad.utils.MMKVUtil
import com.bbitcn.f8.pad.utils.TTSManager
import com.bbitcn.f8.pad.utils.global.Global
import com.bbitcn.f8.pad.utils.log.CrashHandlerUtil
import com.bbitcn.f8.pad.utils.log.MyLog
import com.blankj.utilcode.util.ActivityUtils
import com.iflytek.cloud.SpeechConstant
import com.iflytek.cloud.SpeechUtility
import org.xutils.x
import timber.log.Timber
/**
* @Description APPLICATION类
* @Author DuanKaiji
* @CreateTime 2024年03月27日 13:43
*/
val M = Modifier
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
val MD = Modifier
val IS_DEBUG_DRYCOCOON = true // 是否是调试状态
class MyApp : android.app.Application() {
override fun onCreate() {
super.onCreate()
// 初始化MMKV
MMKVUtil.init(android.content.ContextWrapper.getApplicationContext)
// 初始化崩溃捕捉
CrashHandlerUtil.init()
// 初始化日志库
Timber.plant(MyLog())
// 初始化网络请求库
x.Ext.init(this)
// 初始化全局变量
MMKVUtil.put(Global.DEVICE_ID, android.provider.Settings.Secure.getString(android.content.ContextWrapper.getContentResolver, android.provider.Settings.Secure.ANDROID_ID))
// 初始化讯飞语音
SpeechUtility.createUtility(android.content.ContextWrapper.getApplicationContext, SpeechConstant.APPID +"=5d0fed03")
// 初始化文本转语音
TTSManager.init(android.content.ContextWrapper.getApplicationContext)
MyLog.test("设备唯一码:${Global.getDeviceId()}")
}
companion object {
@JvmStatic
val appContext: android.content.Context
get() = ActivityUtils.getTopActivity()
}
}
+77
View File
@@ -0,0 +1,77 @@
import org.gradle.kotlin.dsl.implementation
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.bbitcn.bbitshow"
compileSdk = 35
defaultConfig {
applicationId = "com.bbitcn.bbitshow"
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
configurations.all {
resolutionStrategy.eachDependency {
if (requested.group == "org.jetbrains.kotlin") {
useVersion("2.0.21")
}
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation( "org.java-websocket:Java-WebSocket:1.6.0")
//lifecycle-viewmodel-compose
implementation(libs.androidx.lifecycle.viewmodel.compose)
// MD3
implementation("androidx.compose.material3:material3:1.4.0-alpha12")
implementation("com.google.code.gson:gson:2.13.1")
//util
implementation("com.blankj:utilcodex:1.31.1")
implementation("org.mozilla.geckoview:geckoview:142.0.20250811145442")
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="CoarseFineLocation">
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<meta-data android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" />
<application
android:allowBackup="true"
android:banner="@mipmap/logo"
android:icon="@mipmap/logo"
android:usesCleartextTraffic="true"
android:name=".MyApp"
android:label="@string/app_name"
android:hardwareAccelerated="true"
android:supportsRtl="true"
android:theme="@style/Theme.BBITShow">
<activity
android:name=".TvActivity"
android:exported="true">
<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>
</application>
</manifest>
@@ -0,0 +1,199 @@
package com.bbitcn.bbitshow
import android.media.MediaPlayer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.net.SocketException
import kotlin.coroutines.cancellation.CancellationException
open class BaseViewModel : ViewModel() {
protected var pollingTask: PollingTask =
PollingTask.getInstance(javaClass.simpleName) // 使用类名作为ID
protected fun doInUIThread(task: () -> Unit) {
viewModelScope.launch {
withContext(Dispatchers.Main) {
task()
}
}
}
protected fun <T> doInIoThreadThenUI(
loadingTips: String = "正在加载中",
showDialog: Boolean = true,
onError: (Throwable) -> Unit = { },
onIO: suspend () -> T,
onFinish: () -> Unit = { },
onUI: (T) -> Unit,
) {
viewModelScope.launch {
val result = runCatching {
withContext(Dispatchers.IO) {
if (showDialog) {
// Toasty.showLoadingDialog(loadingTips)
}
onIO()
}
}
if (showDialog) {
// hideLoadingDialog()
}
withContext(Dispatchers.Main) {
result.onSuccess { data ->
onUI(data)
}.onFailure { exception ->
// ✅ 如果是协程取消,不处理,只记录日志
if (exception is CancellationException || exception.cause is SocketException && exception.cause?.message?.contains(
"Socket closed"
) == true
) {
return@onFailure
}
// 其他异常继续处理
exception.printStackTrace()
onError(exception)
exception.message?.let {
// Toasty.error(it)
}
}.also {
// ✅ 最终执行的操作
onFinish()
}
}
}
}
fun <T> doInIoThread(
loadingTips: String = "正在加载中",
showDialog: Boolean = true,
onError: (Throwable) -> Unit = { },
onFinish: () -> Unit = { },
doInIO: suspend () -> T,
) {
doInIoThreadThenUI(loadingTips, showDialog, onError, doInIO, onFinish) { }
}
fun <T> doInIoThreadNoDialog(
onError: (Throwable) -> Unit = { },
task: suspend () -> T,
) {
doInIoThread(showDialog = false, doInIO = task, onError = onError)
}
/**
* 在IO线程中执行任务,可选择是否显示加载对话框
*/
fun doInIoThreadWith(showLoading: Boolean, loadingTips: String, function: suspend () -> Unit) {
if (showLoading) {
doInIoThread(loadingTips) { function() }
} else {
doInIoThreadNoDialog { function() }
}
}
/**
* 启动一个无限轮询任务
*
* @param pollingInterval 轮询间隔时间(单位:秒)
* @param pollingTask 轮询任务的挂起函数
*/
fun polling(intervalSeconds: Long, task: suspend () -> Unit) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
while (true) {
task()
delay(intervalSeconds * 1000L) // 转换秒为毫秒
}
}
}
}
/**
* 延迟开始轮询任务
*/
suspend fun delayPolling(delaySeconds: Long, intervalSeconds: Long, task: suspend () -> Unit) {
delay(delaySeconds * 1000L)
polling(intervalSeconds, task)
}
private val taskMap = mutableMapOf<String, Job>()
/**
* 放弃旧任务,执行新任务
*
* @param key 任务的唯一标识符
* @param block 任务的挂起函数,必须使用协程,不能开启新的协程,否则无法取消任务
*/
fun launchTaskNewFirst(
key: String,
block: suspend () -> Unit
) {
taskMap[key]?.cancel() // 取消旧任务
taskMap[key] = viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
block()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
/**
* 有新任务时,取消。优先执行旧任务直到完成
*
* @param key 任务的唯一标识符
* @param block 任务函数,可以自由新建协程,但一定要在任务完成时调用 onFinished 回调,否则会导致后续任务永远无法执行
*/
fun launchTaskOldFirst(
key: String,
block: (onFinished: () -> Unit) -> Unit
) {
val existing = taskMap[key]
if (existing?.isActive == true) {
return
}
val job = viewModelScope.launch {
try {
suspendCancellableCoroutine<Unit> { continuation ->
block {
if (continuation.isActive) {
// continuation.resume(Unit) { cause, _, _ ->
// println("resume 后任务被取消: $cause")
// }
}
taskMap.remove(key)
}
}
} catch (e: Exception) {
e.printStackTrace()
taskMap.remove(key)
}
}
taskMap[key] = job
}
override fun onCleared() {
super.onCleared()
taskMap.values.forEach { it.cancel() }
taskMap.clear()
}
}
@@ -0,0 +1,29 @@
package com.bbitcn.bbitshow
import android.app.Application
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.ui.Modifier
/**
* @Description APPLICATION类
* @Author DuanKaiji
* @CreateTime 2024年03月27日 13:43
*/
val M = Modifier
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
val MD = Modifier
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
}
}
@@ -0,0 +1,34 @@
package com.bbitcn.bbitshow
import com.bbitcn.bbitshow.utls.MyUtils
import com.bbitcn.bbitshow.utls.PlanTempDB
import com.google.gson.GsonBuilder
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import org.json.JSONObject
import java.net.InetSocketAddress
import kotlin.toString
class MyWebSocketServer(
address: InetSocketAddress,
val onMessageReceived: (conn: WebSocket?,Map<String, String>) -> Unit
) : WebSocketServer(address) {
override fun onMessage(conn: WebSocket?, message: String?) {
message?.let {
val json = JSONObject(it)
val map = mutableMapOf<String, String>()
json.keys().forEach { key -> map[key] = json.optString(key) }
onMessageReceived(conn,map)
}
}
override fun onOpen(conn: WebSocket?, handshake: ClientHandshake?) {// 发送方案数据
conn?.send(MyUtils.getMyPlanJsonStr())
}
override fun onClose(conn: WebSocket?, code: Int, reason: String?, remote: Boolean) {}
override fun onError(conn: WebSocket?, ex: Exception?) {}
override fun onStart() {
}
}
@@ -0,0 +1,160 @@
package com.bbitcn.bbitshow
import android.os.Handler
import android.os.Looper
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
class PollingTask private constructor() {
private var scheduler: ScheduledExecutorService? = null
private var taskMap: MutableMap<String, ScheduledFuture<*>>? = null
// 私有化构造函数,确保外部不能直接实例化
init {
createNewScheduler()
}
// 创建新的 ScheduledExecutorService 实例
private fun createNewScheduler() {
if (scheduler != null && !scheduler!!.isShutdown) {
scheduler!!.shutdown()
}
scheduler = Executors.newScheduledThreadPool(3)
taskMap = HashMap()
}
/**
* 启动延迟任务
*
* @param taskId 任务ID,用于唯一标识该任务
* @param delaySeconds 延迟时间(以秒为单位)
* @param task 需要执行的任务
*/
fun startDelayedTask(taskId: String, delaySeconds: Long, task: Runnable) {
stopTask(taskId) // 如果已有相同ID的任务,先停止
val future = scheduler!!.schedule({
task.run() // 执行任务
}, delaySeconds, TimeUnit.SECONDS)
taskMap!![taskId] = future // 保存任务的ScheduledFuture
}
/**
* 启动IO线程的轮询任务
*
* @param taskId 任务ID,用于唯一标识该任务
* @param intervalSeconds 轮询间隔时间(以秒为单位)
* @param task 需要在IO线程中执行的任务
*/
fun startPollingTaskOnIOThread(taskId: String, intervalSeconds: Long, task: Runnable) {
startPollingTask(taskId, intervalSeconds) {
task.run()
}
}
/**
* 启动UI线程的轮询任务
*
* @param taskId 任务ID,用于唯一标识该任务
* @param intervalSeconds 轮询间隔时间(以秒为单位)
* @param task 需要在UI线程中执行的任务
*/
fun startPollingTaskOnUIThread(taskId: String, intervalSeconds: Long, task: Runnable) {
startPollingTask(taskId, intervalSeconds) {
// 执行UI线程任务(需要在UI线程中执行)
Handler(Looper.getMainLooper()).post(task)
}
}
/**
* 启动IO线程任务并在UI线程中处理结果的轮询任务
*
* @param taskId 任务ID,用于唯一标识该任务
* @param intervalSeconds 轮询间隔时间(以秒为单位)
* @param ioTask 需要在IO线程中执行的任务
* @param uiTask 需要在UI线程中执行的任务
* @param <T> IO任务返回结果的类型
</T> */
fun <T> startPollingTask(
taskId: String,
intervalSeconds: Long,
ioTask: IRxIOTask<T>,
uiTask: IRxUITask<T>
) {
startPollingTask(taskId, intervalSeconds) {
// 执行IO线程任务
val result = ioTask.doInIOThread()
// 将结果传递给UI线程任务
Handler(Looper.getMainLooper()).post { uiTask.doInUIThread(result) }
}
}
/**
* 启动基础轮询任务,供内部使用
*
* @param taskId 任务ID,用于唯一标识该任务
* @param intervalSeconds 轮询间隔时间(以秒为单位)
* @param task 需要执行的任务
*/
private fun startPollingTask(taskId: String, intervalSeconds: Long, task: Runnable) {
stopTask(taskId) // 如果已有相同ID的任务,先停止
val future = scheduler!!.scheduleWithFixedDelay(task, 0, intervalSeconds, TimeUnit.SECONDS)
// val future = scheduler!!.scheduleAtFixedRate(task, 0, intervalSeconds, TimeUnit.SECONDS)
taskMap!![taskId] = future // 保存任务的ScheduledFuture
}
// 停止所有任务
fun stopAllTasks() {
for (taskId in taskMap!!.keys) {
// test("关闭轮询任务$taskId")
}
if (scheduler != null && !scheduler!!.isShutdown) {
scheduler!!.shutdownNow() // 超时后强制停止
taskMap!!.clear() // 清空任务映射
}
// 从实例映射中移除当前实例
instances.values.remove(this)
}
/**
* 停止轮询任务
*
* @param taskId 任务ID
*/
fun stopTask(taskId: String) {
val future = taskMap!![taskId]
if (future != null) {
future.cancel(true) // 取消任务,中断正在执行的任务
taskMap!!.remove(taskId) // 从任务列表中移除
}
}
// 定义IO任务接口
interface IRxIOTask<T> {
fun doInIOThread(): T
}
// 定义UI任务接口
interface IRxUITask<T> {
fun doInUIThread(t: T)
}
companion object {
private val instances: MutableMap<String, PollingTask> = HashMap()
@Synchronized
fun getInstance(): PollingTask = getInstance("MAIN")
// 获取基于ID的单例实例
@Synchronized
fun getInstance(id: String): PollingTask {
if (!instances.containsKey(id)) {
instances[id] = PollingTask()
}
return instances[id]!!
}
}
}
@@ -0,0 +1,188 @@
package com.bbitcn.bbitshow
import android.R.attr.port
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import android.os.Bundle
import android.provider.Settings
import android.text.format.Formatter
import android.view.KeyEvent
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ModalNavigationDrawer
import androidx.tv.material3.Text
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Button
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import kotlinx.coroutines.launch
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.sp
import org.mozilla.geckoview.GeckoView
@OptIn(ExperimentalTvMaterial3Api::class)
class TvActivity : ComponentActivity() {
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WebView.setWebContentsDebuggingEnabled(true)
CookieManager.getInstance().setAcceptCookie(true)
val viewModel = viewModel<TvViewModel>()
registerService(8080, this)
Surface(
modifier = M.fillMaxSize()
) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
ModalNavigationDrawer(
modifier = Modifier
.fillMaxSize()
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown) {
when (keyEvent.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_BACK -> { // 遥控器菜单键
scope.launch {
if (drawerState.isClosed) drawerState.open()
else drawerState.close()
}
true // 拦截事件
}
else -> false
}
} else false
},
gesturesEnabled = drawerState.isOpen,
drawerState = drawerState,
drawerContent = {
Column(
modifier = M
.fillMaxHeight()
.selectableGroup()
.width(200.dp)
) {
Text(
"TV IP: ${getLocalIpAddress() ?: "未知IP"}",
fontSize = 20.sp,
modifier = Modifier.padding(16.dp)
)
}
}
) {
// val currentIndex by viewModel.currentIndex.collectAsState()
// val pages by viewModel.pages.collectAsState()
// val state = rememberPagerState { pages.size }
// LaunchedEffect(currentIndex) {
// if (currentIndex in 0 until pages.size) {
// state.animateScrollToPage(currentIndex)
// }
// }
// VerticalPager(state = state) { it ->
// // 拿出对应 WebView
// pages.getOrNull(it)?.let { wv ->
// AndroidView(modifier = Modifier.fillMaxSize(), factory = { context ->
// // 确保 WebView 没有父布局
// (wv.parent as? ViewGroup)?.removeView(wv)
// wv
// } )
// }
// }
val currentIndex by viewModel.currentIndex.collectAsState()
val pages by viewModel.pages.collectAsState()
val state = rememberPagerState { pages.size }
LaunchedEffect(currentIndex) {
if (currentIndex in 0 until pages.size) {
state.animateScrollToPage(currentIndex)
}
}
VerticalPager(state = state, beyondViewportPageCount = pages.size) { index ->
pages.getOrNull(index)?.let { session ->
AndroidView(modifier = Modifier.fillMaxSize(), factory = { context ->
GeckoView(context).apply {
// 每个页面实例化一个 GeckoView,然后 attach 已存在的 Session
setSession(session.session)
}
})
}
}
}
}
}
}
private fun getLocalIpAddress(): String? {
val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
return Formatter.formatIpAddress(wm.connectionInfo.ipAddress)
}
private var registrationListener: NsdManager.RegistrationListener? = null
private var nsdManager: NsdManager? = null
fun registerService(port: Int, context: Context) {
val serviceInfo = NsdServiceInfo().apply {
serviceName = Settings.Global.getString(context.contentResolver, "device_name")
serviceType = "_bbit_show._tcp."
setPort(port)
}
nsdManager = context.getSystemService(NSD_SERVICE) as NsdManager
registrationListener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(serviceInfo: NsdServiceInfo?) {}
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {}
override fun onServiceUnregistered(serviceInfo: NsdServiceInfo?) {}
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {}
}
nsdManager?.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
}
fun unregisterService() {
registrationListener?.let {
nsdManager?.unregisterService(it)
}
registrationListener = null
nsdManager = null
}
override fun onDestroy() {
super.onDestroy()
unregisterService()
}
}
@@ -0,0 +1,176 @@
package com.bbitcn.bbitshow
import android.R.attr.tag
import android.content.Context
import android.net.http.SslError
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.provider.Settings
import android.util.Log
import android.view.View
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import com.bbitcn.bbitshow.model.GeckoPage
import com.bbitcn.bbitshow.model.Plan
import com.bbitcn.bbitshow.utls.MyUtils
import com.bbitcn.bbitshow.utls.PlanTempDB
import com.blankj.utilcode.util.ActivityUtils
import com.blankj.utilcode.util.AppUtils
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONArray
import org.json.JSONObject
import java.net.InetSocketAddress
import java.util.UUID
import kotlin.to
import org.mozilla.geckoview.AllowOrDeny
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoSession
import kotlin.collections.getOrNull
import kotlin.collections.indexOfFirst
import kotlin.collections.plus
class TvViewModel : BaseViewModel() {
private var server: MyWebSocketServer? = null
private val _currentIndex = MutableStateFlow(0)
val currentIndex = _currentIndex.asStateFlow()
/**
* 自己的计划
*/
private val _plan = MutableStateFlow<Plan?>(null)
val plan = _plan.asStateFlow()
val port = 8080
/**
* 页面列表
*/
// private val _pages = MutableStateFlow<List<WebView>>(listOf())
// val pages = _pages.asStateFlow()
private val _pages = MutableStateFlow<List<GeckoPage>>(listOf())
val pages = _pages.asStateFlow()
init {
if (server == null) {
server = MyWebSocketServer(InetSocketAddress(port)) { conn, msg ->
doInUIThread {
when (msg["action"]) {
// 基本控制
"next" -> goForward()
"prev" -> goBack()
"reload" -> reloadCurrent()
"stop" -> stopLoad()
// 多页控制
"nextPage" -> switchPage(1)
"prevPage" -> switchPage(-1)
"reload_all" -> reloadAll()
"switchPage" -> switchPageById(UUID.fromString(msg["data"] ?: ""))
"load_list" -> {
val jsonString = msg["data"].toString()
val listType = object : TypeToken<Plan>() {}.type
val plan: Plan = Gson().fromJson(jsonString, listType)
_plan.value = plan
PlanTempDB.init(plan)
plan.pages.sortedBy { it.sort }
initGeckoPages(plan)
conn?.send(MyUtils.getMyPlanJsonStr())
}
}
Log.d("TvViewModel", "Received message: $msg")
}
}
server?.start()
}
// 读取本地保存的页面
doInIoThreadThenUI(onIO = {
PlanTempDB.getData()
}) {
initGeckoPages(it)
}
}
/**
* 网页的停止读取方法
*/
private fun stopLoad() {
_pages.value.getOrNull(_currentIndex.value)?.session?.stop()
}
/**
* 网页后退
*/
fun goBack() {
_pages.value.getOrNull(_currentIndex.value)?.let { webView ->
webView.session.goBack()
}
}
/**
* 网页前进
*/
fun goForward() {
_pages.value.getOrNull(_currentIndex.value)?.let { webView ->
webView.session.goForward()
}
}
/**
* 重新加载当前页面
*/
fun reloadCurrent() {
_pages.value.getOrNull(_currentIndex.value)?.session?.reload()
}
fun reloadAll() {
_pages.value.forEach {
it.session.reload()
}
}
fun switchPage(delta: Int) {
val newIndex = (_currentIndex.value + delta).coerceIn(0, _pages.value.size - 1)
_currentIndex.value = newIndex
}
fun switchPageById(id: UUID) {
val newIndex = _pages.value.indexOfFirst { it.id == id }
_currentIndex.value = newIndex
}
private val runtime by lazy {
GeckoRuntime.create(ActivityUtils.getTopActivity())
}
private fun initGeckoPages(plan: Plan) {
_pages.value = listOf()
plan.pages.forEach { page ->
val session = GeckoSession().apply { open(runtime) }
session.loadUri(page.url)
// SSL 错误/URL 拦截 → GeckoView 走的是 ProgressDelegate、PermissionDelegate、NavigationDelegate
session.navigationDelegate = object : GeckoSession.NavigationDelegate {
override fun onLoadRequest(
session: GeckoSession,
request: GeckoSession.NavigationDelegate.LoadRequest
): GeckoResult<AllowOrDeny> {
// 相当于 shouldOverrideUrlLoading
return GeckoResult.fromValue(AllowOrDeny.ALLOW)
}
}
val geckoPage = GeckoPage(id = page.id, session = session)
_pages.update { current -> current + geckoPage }
}
_currentIndex.value = 0
}
}
@@ -0,0 +1,39 @@
package com.bbitcn.sericulture.base
import android.content.Context
import com.bbitcn.bbitshow.utls.SPUtils
import com.google.gson.Gson
import java.lang.reflect.Type
/**
*
* @Description 临时数据库(单实体,列表请用@BaseListTempDataBase
* @Author DuanKaiji
* @CreateTime 2024年08月06日 11:39:31
*/
abstract class BaseTempDataBase<T> {
abstract fun getKey(): String
abstract fun defaultData(): T
abstract fun getType(): Type
fun init(value :T): Boolean {
SPUtils.put(getKey(), Gson().toJson(value))
return true
}
fun getData(): T {
val json = SPUtils.get(getKey(), Gson().toJson(defaultData()))
return Gson().fromJson(json, getType())
}
fun clear(): Boolean {
SPUtils.put(getKey(), "")
return true
}
fun putString(context: Context, key: String, value: String) {
val sp = context.getSharedPreferences("my_prefs", Context.MODE_PRIVATE)
sp.edit().putString(key, value).apply() // apply() 异步提交
}
}
@@ -0,0 +1,18 @@
package com.bbitcn.bbitshow.base
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
class ParameterizedTypeImpl(private var clazz: Class<*>) : ParameterizedType {
override fun getActualTypeArguments(): Array<Type> {
return arrayOf(clazz)
}
override fun getRawType(): Type {
return MutableList::class.java
}
override fun getOwnerType(): Type? {
return null
}
}
@@ -0,0 +1,9 @@
package com.bbitcn.bbitshow.model
import org.mozilla.geckoview.GeckoSession
import java.util.UUID
data class GeckoPage(
val id: UUID,
val session: GeckoSession
)
@@ -0,0 +1,10 @@
package com.bbitcn.bbitshow.model
import java.util.UUID
data class Page(
var id: UUID,
var sort: Int,
var name: String,
var url: String
)
@@ -0,0 +1,13 @@
package com.bbitcn.bbitshow.model
import java.util.Date
import java.util.UUID
data class Plan(
val id: UUID,
val name: String,
val createTime: Date,
val cycleRefreshTime :Int,
val pages: List<Page>
)
@@ -0,0 +1,11 @@
package com.bbitcn.bbitshow.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,34 @@
package com.bbitcn.bbitshow.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
import androidx.tv.material3.lightColorScheme
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun BBITShowTheme(
isInDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (isInDarkTheme) {
darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
} else {
lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,36 @@
package com.bbitcn.bbitshow.ui.theme
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Typography
// Set of Material typography styles to start with
@OptIn(ExperimentalTvMaterial3Api::class)
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,16 @@
package com.bbitcn.bbitshow.utls
import com.google.gson.GsonBuilder
import org.json.JSONObject
object MyUtils {
fun getMyPlanJsonStr(): String {
val gson = GsonBuilder()
.setDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
.create()
val map = mutableMapOf<String, String>()
map["type"] = "tv"
map["data"] = gson.toJson(PlanTempDB.getData())
return JSONObject(map).toString()
}
}
@@ -0,0 +1,33 @@
package com.bbitcn.bbitshow.utls
import com.bbitcn.bbitshow.model.Page
import com.bbitcn.bbitshow.model.Plan
import com.bbitcn.sericulture.base.BaseTempDataBase
import com.google.gson.reflect.TypeToken
import java.util.Date
import java.util.UUID
import java.lang.reflect.Type
object PlanTempDB :BaseTempDataBase<Plan>(){
override fun getKey(): String {
return "FRP_CONFIG_TEMP"
}
override fun defaultData(): Plan {
return Plan(id = UUID.randomUUID(),
name ="默认配置",
createTime = Date(),
cycleRefreshTime = 0,
pages = listOf(Page(
id = UUID.randomUUID(),
sort = 0,
name = "默认页面",
url = "https://www.baidu.com",
)))
}
override fun getType(): Type {
return object : TypeToken<Plan>() {}.type
}
}
@@ -0,0 +1,28 @@
package com.bbitcn.bbitshow.utls
import android.content.Context
import com.blankj.utilcode.util.ActivityUtils
import com.blankj.utilcode.util.AppUtils
object SPUtils {
private const val PREF_NAME = "app_prefs"
// 存储 String
fun put( key: String, value: String) {
val sp = ActivityUtils.getTopActivity().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
sp.edit().putString(key, value).apply()
}
// 读取 String
fun get(key: String, defaultValue: String = ""): String {
val sp = ActivityUtils.getTopActivity().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
return sp.getString(key, defaultValue) ?: defaultValue
}
// 可选:清空所有
fun clear() {
val sp = ActivityUtils.getTopActivity().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
sp.edit().clear().apply()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">BBIT展示屏</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<style name="Theme.BBITShow" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>