commit ec8289d0f5c76343c57d74993e0314cd87c7ec0c
Author: BBIT-Kai <2911862937@qq.com>
Date: Mon May 25 15:27:47 2026 +0800
初始化项目
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..11e6be0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+.kotlin/
+.idea/
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..952b930
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..55d8458
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,45 @@
+[versions]
+agp = "8.10.1"
+kotlin = "2.0.21"
+coreKtx = "1.10.1"
+junit = "4.13.2"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
+appcompat = "1.6.1"
+material = "1.10.0"
+activity = "1.8.0"
+constraintlayout = "2.1.4"
+composeBom = "2024.09.00"
+tvFoundation = "1.0.0-alpha07"
+tvMaterial = "1.0.1"
+lifecycleRuntimeKtx = "2.6.1"
+activityCompose = "1.8.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
+androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+
+androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..3d3fa12
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Aug 18 14:55:29 CST 2025
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/phone/.gitignore b/phone/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/phone/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/phone/build.gradle.kts b/phone/build.gradle.kts
new file mode 100644
index 0000000..51fe9c6
--- /dev/null
+++ b/phone/build.gradle.kts
@@ -0,0 +1,68 @@
+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.phone"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.bbitcn.phone"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ 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)
+ //MMKV
+ implementation("com.tencent:mmkv:2.2.2")
+ implementation("com.google.code.gson:gson:2.13.1")
+
+}
\ No newline at end of file
diff --git a/phone/proguard-rules.pro b/phone/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/phone/proguard-rules.pro
@@ -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
\ No newline at end of file
diff --git a/phone/src/androidTest/java/com/bbitcn/phone/ExampleInstrumentedTest.kt b/phone/src/androidTest/java/com/bbitcn/phone/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..98ee1e9
--- /dev/null
+++ b/phone/src/androidTest/java/com/bbitcn/phone/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.bbitcn.phone
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.bbitcn.phone", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/phone/src/main/AndroidManifest.xml b/phone/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b2cf34f
--- /dev/null
+++ b/phone/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/MyApp.kt b/phone/src/main/java/com/bbitcn/phone/MyApp.kt
new file mode 100644
index 0000000..3d2b01a
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/MyApp.kt
@@ -0,0 +1,34 @@
+package com.bbitcn.phone
+
+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.phone.utls.MMKVUtil
+
+
+/**
+ * @Description APPLICATION类
+ * @Author DuanKaiji
+ * @CreateTime 2024年03月27日 13:43
+ */
+val M = Modifier
+val MD = Modifier
+ .animateContentSize(
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ stiffness = Spring.StiffnessLow
+ )
+ )
+
+
+class MyApp : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ // 初始化MMKV
+ MMKVUtil.init(applicationContext)
+ }
+}
diff --git a/phone/src/main/java/com/bbitcn/phone/base/BaseListTempDataBase.kt b/phone/src/main/java/com/bbitcn/phone/base/BaseListTempDataBase.kt
new file mode 100644
index 0000000..68475cb
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/base/BaseListTempDataBase.kt
@@ -0,0 +1,75 @@
+package com.bbitcn.sericulture.base
+
+import com.bbitcn.phone.utls.MMKVUtil
+import com.google.gson.Gson
+import java.lang.reflect.Type
+
+/**
+ *
+ * @Description 列表临时存储库
+ * @Author DuanKaiji
+ * @CreateTime 2024年08月06日 11:39:31
+ */
+abstract class BaseListTempDataBase {
+ protected val gson: Gson = Gson()
+
+ abstract fun getKey(): String
+
+ abstract fun getType(): Type
+
+ fun getAll(): MutableList {
+ val json = MMKVUtil.get(getKey(), "[]")
+ return Gson().fromJson(json, getType())
+ }
+
+ suspend fun init(data: List) {
+ MMKVUtil.put(getKey(), gson.toJson(data))
+ }
+
+ suspend fun insert(t: T, afterInsert: (T) -> Unit = {}): Boolean {
+// val type: Type = ParameterizedTypeImpl(T::class.java)
+ val temp: MutableList = getAll()
+ if (temp.contains(t)) {
+ return false
+ }
+ temp.add(t)
+ MMKVUtil.put(getKey(), gson.toJson(temp))
+ afterInsert(t)
+ return true
+ }
+
+ /**
+ * 更新
+ */
+ fun update(predicate: (T) -> Boolean, update: (T) -> Unit = {}, afterUpdate: () -> Unit = {}): Boolean {
+ val temp: MutableList = getAll()
+ for (mode in temp) {
+ if (predicate(mode)) {
+ update(mode)
+ MMKVUtil.put(getKey(), gson.toJson(temp))
+ afterUpdate()
+ return true
+ }
+ }
+ return false
+ }
+
+ fun delete(predicate: (T) -> Boolean,afterDel: (T) -> Unit = { }): Boolean {
+ val temp: MutableList = getAll()
+ val iterator = temp.iterator()
+ while (iterator.hasNext()) {
+ val mode = iterator.next()
+ if (predicate(mode)) {
+ iterator.remove()
+ MMKVUtil.put(getKey(), gson.toJson(temp))
+ afterDel(mode)
+ return true
+ }
+ }
+ return false
+ }
+ fun clear(): Boolean {
+ MMKVUtil.put(getKey(), "[]")
+ return true
+ }
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/base/BaseViewModel.kt b/phone/src/main/java/com/bbitcn/phone/base/BaseViewModel.kt
new file mode 100644
index 0000000..d5562cd
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/base/BaseViewModel.kt
@@ -0,0 +1,194 @@
+package com.bbitcn.phone.base
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.bbitcn.phone.utls.PollingTask
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+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.Companion.getInstance(javaClass.simpleName) // 使用类名作为ID
+
+ protected fun doInUIThread(task: () -> Unit) {
+ viewModelScope.launch {
+ withContext(Dispatchers.Main) {
+ task()
+ }
+ }
+ }
+
+ protected fun 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 doInIoThread(
+ loadingTips: String = "正在加载中",
+ showDialog: Boolean = true,
+ onError: (Throwable) -> Unit = { },
+ onFinish: () -> Unit = { },
+ doInIO: suspend () -> T,
+ ) {
+ doInIoThreadThenUI(loadingTips, showDialog, onError, doInIO, onFinish) { }
+ }
+
+ fun 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()
+
+ /**
+ * 放弃旧任务,执行新任务
+ *
+ * @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 { 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()
+ }
+
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/base/MyAnimatedVisibility.kt b/phone/src/main/java/com/bbitcn/phone/base/MyAnimatedVisibility.kt
new file mode 100644
index 0000000..023c5ef
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/base/MyAnimatedVisibility.kt
@@ -0,0 +1,30 @@
+package com.bbitcn.phone.base
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bbitcn.phone.M
+
+@Composable
+fun MyAnimatedVisibility(
+ visible: Boolean,
+ modifier: Modifier = M,
+ enter: EnterTransition = fadeIn(),
+ exit: ExitTransition = fadeOut(),
+ label: String = "AnimatedVisibility",
+ content: @Composable AnimatedVisibilityScope.() -> Unit
+) {
+ AnimatedVisibility(
+ visible = visible,
+ modifier = modifier,
+ enter = enter,
+ exit = exit,
+ label = label,
+ content = content
+ )
+}
diff --git a/phone/src/main/java/com/bbitcn/phone/base/MyBottomSheet.kt b/phone/src/main/java/com/bbitcn/phone/base/MyBottomSheet.kt
new file mode 100644
index 0000000..26c9c67
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/base/MyBottomSheet.kt
@@ -0,0 +1,110 @@
+package com.bbitcn.phone.base
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.bbitcn.phone.M
+import kotlinx.coroutines.delay
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.padding
+
+@Composable
+fun MyBottomSheet(
+ showBottomSheet: Boolean,
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+ sheetHeight: Dp = 500.dp,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ var showSheet by remember { mutableStateOf(false) }
+
+ // 拦截返回键
+ if (showBottomSheet) {
+ BackHandler {
+ onDismissRequest()
+ }
+ }
+
+ LaunchedEffect(showBottomSheet) {
+ if (showBottomSheet) {
+ showSheet = true
+ } else {
+ showSheet = false
+ }
+ }
+
+ if (showBottomSheet || showSheet) {
+ Box(modifier = Modifier.fillMaxSize().padding(bottom = 100.dp)) {
+ AnimatedVisibility(
+ visible = showBottomSheet,
+ enter = fadeIn(animationSpec = tween(200)),
+ exit = fadeOut(animationSpec = tween(200))
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0x99000000))
+ .noVisualFeedbackClickable { onDismissRequest() }
+ )
+ }
+
+ AnimatedVisibility(
+ visible = showSheet,
+ enter = slideInVertically(
+ initialOffsetY = { it },
+ animationSpec = tween(250, easing = FastOutSlowInEasing)
+ ),
+ exit = slideOutVertically(
+ targetOffsetY = { it },
+ animationSpec = tween(200, easing = FastOutSlowInEasing)
+ ),
+ modifier = Modifier.align(Alignment.BottomCenter)
+ ) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(sheetHeight)
+ .background(
+ Color.White,
+ shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
+ )
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) {}
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ content = content
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/phone/src/main/java/com/bbitcn/phone/base/ParameterizedTypeImpl.kt b/phone/src/main/java/com/bbitcn/phone/base/ParameterizedTypeImpl.kt
new file mode 100644
index 0000000..c13ba54
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/base/ParameterizedTypeImpl.kt
@@ -0,0 +1,18 @@
+package com.bbitcn.phone.base
+
+import java.lang.reflect.ParameterizedType
+import java.lang.reflect.Type
+
+class ParameterizedTypeImpl(private var clazz: Class<*>) : ParameterizedType {
+ override fun getActualTypeArguments(): Array {
+ return arrayOf(clazz)
+ }
+
+ override fun getRawType(): Type {
+ return MutableList::class.java
+ }
+
+ override fun getOwnerType(): Type? {
+ return null
+ }
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/base/noVisualFeedbackClickable.kt b/phone/src/main/java/com/bbitcn/phone/base/noVisualFeedbackClickable.kt
new file mode 100644
index 0000000..1db9356
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/base/noVisualFeedbackClickable.kt
@@ -0,0 +1,21 @@
+package com.bbitcn.phone.base
+
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+
+/**
+ * 自定义点击事件
+ * 不使用clickable的点击效果
+ */
+fun Modifier.noVisualFeedbackClickable(
+ onClick: () -> Unit
+): Modifier {
+ return this.pointerInput(Unit) {
+ detectTapGestures(
+ onTap = {
+ onClick()
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/model/Page.kt b/phone/src/main/java/com/bbitcn/phone/model/Page.kt
new file mode 100644
index 0000000..1ac5268
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/model/Page.kt
@@ -0,0 +1,10 @@
+package com.bbitcn.phone.model
+
+import java.util.UUID
+
+data class Page(
+ var id: UUID,
+ var sort: Int,
+ var name: String,
+ var url: String
+)
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/model/Plan.kt b/phone/src/main/java/com/bbitcn/phone/model/Plan.kt
new file mode 100644
index 0000000..0e0d3be
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/model/Plan.kt
@@ -0,0 +1,13 @@
+package com.bbitcn.phone.model
+
+import java.util.Date
+import java.util.UUID
+
+data class Plan(
+ val id: UUID,
+ var name: String,
+ val createTime: Date,
+ var cycleRefreshTime :Int,
+ var pages: List
+
+)
diff --git a/phone/src/main/java/com/bbitcn/phone/ui/PhoneActivity.kt b/phone/src/main/java/com/bbitcn/phone/ui/PhoneActivity.kt
new file mode 100644
index 0000000..6cc9d8c
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/ui/PhoneActivity.kt
@@ -0,0 +1,157 @@
+package com.bbitcn.phone.ui
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarDefaults
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.bbitcn.phone.M
+import com.bbitcn.phone.base.MyBottomSheet
+import com.bbitcn.phone.ui.compose.ConnectScreen
+import com.bbitcn.phone.ui.compose.ControlScreen
+import com.bbitcn.phone.ui.compose.SetScreen
+import kotlinx.coroutines.launch
+
+
+class PhoneActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ val viewModel = viewModel()
+ viewModel.startDiscovery(this)
+ val pagerState = rememberPagerState { 10 }
+ val scope = rememberCoroutineScope()
+ val showBottomSheet by viewModel.isDrawerOpen.collectAsState()
+
+ // 连接、控制、设置
+ val navList = listOf(
+ "连接" to Icons.Default.Phone,
+ "控制" to Icons.Default.PlayArrow,
+ "设置" to Icons.Default.Settings
+ )
+ Scaffold(
+ modifier = M
+ .fillMaxSize(),
+ floatingActionButton = {
+ if( pagerState.currentPage == 2 && !showBottomSheet) {
+ FloatingActionButton(onClick = {
+ // 新增计划
+ viewModel.addPlan()
+ }) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ }
+ }
+ },
+ bottomBar = {
+ NavigationBar(windowInsets = NavigationBarDefaults.windowInsets) {
+ navList.forEachIndexed { index, item ->
+ NavigationBarItem(
+ selected = pagerState.currentPage == index,
+ onClick = {
+ scope.launch {
+ pagerState.animateScrollToPage(index)
+ }
+ },
+ icon = {
+ Icon(
+ item.second,
+ contentDescription = null
+ )
+ },
+ label = { Text(item.first) }
+ )
+ }
+ }
+ }
+ ) { contentPadding ->
+ HorizontalPager(state = pagerState, contentPadding = contentPadding) {
+ Column(modifier = M.fillMaxSize()) {
+ when (it) {
+ 0 -> ConnectScreen(viewModel)
+ 1 -> ControlScreen(viewModel)
+ 2 -> SetScreen(viewModel)
+ }
+ }
+ }
+ MyBottomSheet(
+ showBottomSheet = showBottomSheet,
+ onDismissRequest = {
+ viewModel.closeDrawer()
+ }
+ ) {
+ Box(
+ modifier = M.fillMaxSize()
+ ) {
+ // 底部抽屉
+ viewModel.drawerContent.value()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FunctionModel(name: String, content: @Composable () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(5.dp)
+ ) {
+ Text(
+ name,
+ fontSize = MaterialTheme.typography.headlineMedium.fontSize,
+ modifier = Modifier
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(5.dp)
+ ) {
+ content()
+ }
+ }
+}
+
+@Composable
+fun MyFuncButtonRow(content: @Composable () -> Unit) {
+ LazyRow {
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
+ content()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/ui/PhoneViewModel.kt b/phone/src/main/java/com/bbitcn/phone/ui/PhoneViewModel.kt
new file mode 100644
index 0000000..5108cf6
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/ui/PhoneViewModel.kt
@@ -0,0 +1,525 @@
+package com.bbitcn.phone.ui
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.util.Log
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.bbitcn.phone.M
+import com.bbitcn.phone.base.BaseViewModel
+import com.bbitcn.phone.model.Page
+import com.bbitcn.phone.model.Plan
+import com.bbitcn.phone.utls.PlanTempDatabase
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.google.gson.reflect.TypeToken
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.java_websocket.client.WebSocketClient
+import org.java_websocket.handshake.ServerHandshake
+import org.json.JSONObject
+import java.io.IOException
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.net.URI
+import java.util.Date
+import java.util.UUID
+
+class PhoneViewModel : BaseViewModel() {
+ // 控制 Drawer 内容的 MutableStateFlow
+ private val _drawerContent = MutableStateFlow<@Composable () -> Unit> { }
+ val drawerContent: StateFlow<@Composable () -> Unit> = _drawerContent.asStateFlow()
+
+ /**
+ * 控制 Drawer 状态的 Boolean Flow
+ */
+ private val _isDrawerOpen = MutableStateFlow(false)
+ val isDrawerOpen: StateFlow = _isDrawerOpen.asStateFlow()
+
+ /**
+ * 扫描到的所有设备
+ */
+ private val _devices = MutableStateFlow>(emptyList())
+ val devices = _devices.asStateFlow()
+
+ /**
+ * 设备当前页面列表
+ */
+ private val _devicePagesList = MutableStateFlow(null)
+ val devicePagesList = _devicePagesList.asStateFlow()
+
+ /**
+ * 自己的计划列表
+ */
+ private val _plans = MutableStateFlow>(emptyList())
+ val plans = _plans.asStateFlow()
+
+ /**
+ * 当前连接的设备信息
+ */
+ private val _device = MutableStateFlow(null)
+ val device = _device.asStateFlow()
+
+ /**
+ * 自动刷新状态
+ */
+ private val _autoSearching = MutableStateFlow(true)
+ val autoSearching = _autoSearching.asStateFlow()
+
+ /**
+ * 发现监听器
+ */
+ private var discoveryListener: NsdManager.DiscoveryListener? = null
+ private var client: WebSocketClient? = null
+ lateinit var nsdManager: NsdManager
+ val port = 8080
+
+
+ init {
+ // 刷新个人配置
+ _plans.value = PlanTempDatabase.getAll()
+ }
+
+ fun connectToTv(nsdServiceInfo: NsdServiceInfo) {
+ connectToTvByIp(nsdServiceInfo.host.hostAddress, nsdServiceInfo)
+ }
+
+ fun connectToTvByIp(tvIp: String, nsdServiceInfo: NsdServiceInfo? = null) {
+ doInIoThread {
+ val uri = URI("ws://$tvIp:$port")
+ client = object : WebSocketClient(uri) {
+ override fun onOpen(handshakedata: ServerHandshake?) {
+ _device.value = nsdServiceInfo
+ ?: NsdServiceInfo().apply {
+ serviceName = "手动连接设备"
+ host =
+ URI("http://$tvIp").host?.let { InetAddress.getByName(it) }
+ port = port
+ }
+ }
+
+ override fun onMessage(message: String?) {
+ message?.let {
+ try {
+ val json = JSONObject(it)
+ if (json["type"] == "tv") {
+ val listType = object : TypeToken() {}.type
+ val plan: Plan = Gson().fromJson(json["data"].toString(), listType)
+ plan.pages.sortedBy { it.id }
+ _devicePagesList.value = plan
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
+
+ override fun onClose(code: Int, reason: String?, remote: Boolean) {
+ initConnectedDeviceData()
+ client?.close()
+ client = null
+ }
+
+ override fun onError(ex: Exception?) {
+ ex?.printStackTrace()
+ }
+ }
+ client?.connect()
+ }
+ }
+
+ // 开始发现服务
+ fun startDiscovery(context: Context) {
+ nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
+ discoveryListener = object : NsdManager.DiscoveryListener {
+ override fun onServiceFound(service: NsdServiceInfo) {
+ // 异步解析服务
+ nsdManager.resolveService(service, object : NsdManager.ResolveListener {
+ override fun onServiceResolved(nsdServiceInfo: NsdServiceInfo) {
+ val host = nsdServiceInfo.host.hostAddress
+ val port = nsdServiceInfo.port
+ if (isServiceAvailable(host, port)) {
+ _devices.value =
+ (_devices.value + nsdServiceInfo).distinctBy { it.serviceName }
+ connectToTv(nsdServiceInfo)
+ } else {
+ Log.w("NSD", "服务不可达: $host:$port")
+ }
+ }
+
+ override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {}
+ })
+ }
+
+ override fun onServiceLost(service: NsdServiceInfo) {
+ _devices.value = _devices.value.filter { it.serviceName != service.serviceName }
+ }
+
+ override fun onDiscoveryStarted(serviceType: String) {}
+ override fun onDiscoveryStopped(serviceType: String) {}
+ override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
+ override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
+ }
+
+ nsdManager.discoverServices(
+ "_bbit_show._tcp.",
+ NsdManager.PROTOCOL_DNS_SD,
+ discoveryListener
+ )
+ _autoSearching.value = true
+ }
+
+ fun isServiceAvailable(ip: String, port: Int, timeout: Int = 1000): Boolean {
+ return try {
+ Socket().use { socket ->
+ socket.connect(InetSocketAddress(ip, port), timeout)
+ }
+ true
+ } catch (e: IOException) {
+ e.printStackTrace()
+ false
+ }
+ }
+
+ /**
+ * 手动断开连接
+ */
+ fun disconnect() {
+ client?.close()
+ client = null
+ initConnectedDeviceData()
+ }
+
+ // 停止发现
+ fun stopDiscovery() {
+ discoveryListener?.let {
+ nsdManager.stopServiceDiscovery(it)
+ discoveryListener = null
+ }
+ _autoSearching.value = false
+ }
+
+ /**
+ * 基础控制
+ */
+ fun next() = sendAction("next")
+ fun prev() = sendAction("prev")
+ fun reload() = sendAction("reload")
+ fun stop() = sendAction("stop")
+
+ /**
+ * 多页控制
+ */
+ fun nextPage() = sendAction("nextPage")
+ fun prevPage() = sendAction("prevPage")
+ fun reloadAll() = sendAction("reload_all")
+ fun switchPage(id: UUID) = sendAction("switchPage", id.toString())
+ fun loadList(content: String) = sendAction("load_list", content)
+
+ private fun sendAction(action: String, data: String? = null) {
+ val json = JSONObject()
+ json.put("action", action)
+ if (data != null) json.put("data", data)
+ client?.send(json.toString())
+ Log.d("WebSocket", "发送消息: $json")
+ }
+
+
+ fun closeDrawer() {
+ _isDrawerOpen.value = false
+ }
+
+ fun public() {
+ doInIoThread {
+ _drawerContent.value = {
+ LazyColumn {
+ item {
+ Text(
+ text = "选择方案",
+ modifier = M
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .fillMaxWidth(),
+ fontSize = MaterialTheme.typography.headlineLarge.fontSize,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ items(items = _plans.value) { plan ->
+ Button(
+ modifier = M.fillMaxWidth(),
+ onClick = {
+ // 发送方案数据
+ val gson = GsonBuilder()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
+ .create()
+
+ val jsonString = gson.toJson(plan)
+ loadList(jsonString)
+ _isDrawerOpen.value = false
+ }
+ ) {
+ Text(plan.name)
+ }
+ }
+ }
+ }
+ _isDrawerOpen.value = true
+ }
+ }
+
+ fun addPlan() {
+ doInIoThread {
+ _drawerContent.value = {
+ var name by remember { mutableStateOf("") }
+ var autoRefreshTimes by remember { mutableStateOf("0") }
+ Column {
+ Text(
+ text = "新建方案",
+ modifier = M
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .fillMaxWidth(),
+ fontSize = MaterialTheme.typography.headlineLarge.fontSize,
+ fontWeight = FontWeight.Bold
+ )
+ Column(modifier = M.padding(horizontal = 16.dp)) {
+ TextField(
+ modifier = M.fillMaxWidth(),
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("方案名称") },
+ singleLine = true
+ )
+ TextField(
+ modifier = M.fillMaxWidth(),
+ value = autoRefreshTimes,
+ onValueChange = { autoRefreshTimes = it },
+ label = { Text("自动刷新时间(0为不刷新)") },
+ singleLine = true,
+ // 限制为数字输入
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ Button(modifier = M.fillMaxWidth(), onClick = {
+ doInIoThread {
+ PlanTempDatabase.insert(
+ Plan(
+ id = UUID.randomUUID(),
+ name = name,
+ createTime = Date(),
+ cycleRefreshTime = autoRefreshTimes.toIntOrNull() ?: 0,
+ pages = emptyList()
+ )
+ )
+ _plans.value = PlanTempDatabase.getAll()
+ _isDrawerOpen.value = false
+ }
+ }) { Text("新建") }
+ }
+ }
+ }
+ _isDrawerOpen.value = true
+ }
+ }
+
+ fun editPlan(plan: Plan) {
+ doInIoThread {
+ _drawerContent.value = {
+ var name by remember { mutableStateOf(plan.name) }
+ var autoRefreshTimes by remember { mutableStateOf(plan.cycleRefreshTime.toString()) }
+ Column {
+ Text(
+ text = "编辑方案",
+ modifier = M
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .fillMaxWidth(),
+ fontSize = MaterialTheme.typography.headlineLarge.fontSize,
+ fontWeight = FontWeight.Bold
+ )
+ Column(modifier = M.padding(horizontal = 16.dp)) {
+ TextField(
+ modifier = M.fillMaxWidth(),
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("方案名称") },
+ singleLine = true
+ )
+ TextField(
+ modifier = M.fillMaxWidth(),
+ value = autoRefreshTimes,
+ onValueChange = { autoRefreshTimes = it },
+ label = { Text("自动刷新时间(0为不刷新)") },
+ singleLine = true,
+ // 限制为数字输入
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ Row(
+ modifier = M.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Button(modifier = M.weight(1f), onClick = {
+ doInIoThread {
+ PlanTempDatabase.update({ it.id == plan.id }, update = {
+ it.name = name
+ it.cycleRefreshTime = autoRefreshTimes.toIntOrNull() ?: 0
+ }) {
+ _plans.value = PlanTempDatabase.getAll()
+ _isDrawerOpen.value = false
+ }
+ }
+ }) { Text("确定") }
+ Button(modifier = M.weight(1f), onClick = {
+ PlanTempDatabase.delete({
+ it.id == plan.id
+ }) {
+ _plans.value = PlanTempDatabase.getAll()
+ _isDrawerOpen.value = false
+ }
+ }) { Text("删除") }
+ }
+ }
+ }
+ }
+ _isDrawerOpen.value = true
+ }
+ }
+
+ fun editPage(plan: Plan) {
+ doInIoThread {_drawerContent.value = {
+ // 保存原始快照用于比较
+ val originalList = remember { plan.pages.map { it.copy() } }
+ var list by remember { mutableStateOf(plan.pages.map { it.copy() }) }
+
+ // 判断列表是否被修改
+ val isModified by remember(list) {
+ derivedStateOf {
+ if (list.size != originalList.size) return@derivedStateOf true
+ list.zip(originalList).any { (cur, orig) ->
+ cur.id != orig.id || cur.name != orig.name || cur.url != orig.url || cur.sort != orig.sort
+ }
+ }
+ }
+
+ Column(modifier = M.fillMaxSize()) {
+ Text(
+ text = "方案细则 - ${plan.name}",
+ modifier = M.padding(horizontal = 16.dp, vertical = 8.dp),
+ fontSize = MaterialTheme.typography.headlineLarge.fontSize,
+ fontWeight = FontWeight.Bold
+ )
+
+ Button(modifier = M.fillMaxWidth(), onClick = {
+ list = list + Page(
+ id = UUID.randomUUID(),
+ sort = list.size + 1,
+ name = "新页面",
+ url = ""
+ )
+ }) { Text("新增") }
+
+ LazyColumn(modifier = M.weight(1f)) {
+ itemsIndexed(items = list) { index, page ->
+ var pageSort by remember { mutableStateOf(page.sort.toString()) }
+ var pageName by remember { mutableStateOf(page.name) }
+ var pageUrl by remember { mutableStateOf(page.url) }
+
+ Row(modifier = M.fillMaxWidth().padding(vertical = 5.dp)) {
+ TextField(
+ modifier = M.weight(2f).padding(horizontal = 4.dp),
+ value = pageSort,
+ onValueChange = { newSort ->
+ pageSort = newSort
+ list = list.toMutableList().also { l ->
+ l[index] = l[index].copy(sort = newSort.toIntOrNull() ?: 0)
+ }
+ },
+ label = { Text("序号") },
+ singleLine = true
+ )
+ TextField(
+ modifier = M.weight(3f).padding(horizontal = 4.dp),
+ value = pageName,
+ onValueChange = { newName ->
+ pageName = newName
+ list = list.toMutableList().also { l ->
+ l[index] = l[index].copy(name = newName)
+ }
+ },
+ label = { Text("名称") },
+ singleLine = true
+ )
+ }
+
+ Row(modifier = M.fillMaxWidth().padding(vertical = 5.dp)) {
+ TextField(
+ modifier = M.weight(4f).padding(horizontal = 4.dp),
+ value = pageUrl,
+ onValueChange = { newUrl ->
+ pageUrl = newUrl
+ list = list.toMutableList().also { l ->
+ l[index] = l[index].copy(url = newUrl)
+ }
+ },
+ label = { Text("网址") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
+ )
+ Button(
+ modifier = M.padding(horizontal = 4.dp),
+ onClick = {
+ list = list.filter { it.id != page.id }
+ }
+ ) { Text("删除") }
+ }
+ }
+ }
+
+ if (isModified) {
+ Button(modifier = M.fillMaxWidth(), onClick = {
+ doInIoThread {
+ PlanTempDatabase.update({ it.id == plan.id }, update = {
+ it.pages = list.sortedBy { it.sort }
+ }) {
+ _plans.value = PlanTempDatabase.getAll()
+ _isDrawerOpen.value = false
+ }
+ }
+ }) { Text("保存") }
+ }
+ }
+ }
+
+ _isDrawerOpen.value = true
+ }
+ }
+
+ fun initConnectedDeviceData() {
+ _devicePagesList.value = null
+ _device.value = null
+ _plans.value = PlanTempDatabase.getAll()
+
+ }
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/ui/compose/ConnectScreen.kt b/phone/src/main/java/com/bbitcn/phone/ui/compose/ConnectScreen.kt
new file mode 100644
index 0000000..6801cc6
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/ui/compose/ConnectScreen.kt
@@ -0,0 +1,101 @@
+package com.bbitcn.phone.ui.compose
+
+import android.R.attr.port
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material3.Button
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.bbitcn.phone.ui.FunctionModel
+import com.bbitcn.phone.ui.MyFuncButtonRow
+import com.bbitcn.phone.ui.PhoneViewModel
+
+@Composable
+fun ConnectScreen(viewModel: PhoneViewModel) {
+ var ip by remember { mutableStateOf("10.0.4.74") }
+ val device by viewModel.device.collectAsState()
+ val autoSearching by viewModel.autoSearching.collectAsState()
+
+ FunctionModel("状态信息") {
+ Column {
+ Text(
+ "连接状态:" + if (device == null) "未连接" else "已连接到设备<${device?.serviceName}>",
+ fontSize = MaterialTheme.typography.bodyLarge.fontSize
+ )
+ Text(
+ "扫描状态:" + if (autoSearching) "正在自动搜索中" else "",
+ fontSize = MaterialTheme.typography.bodyLarge.fontSize
+ )
+ }
+ }
+ FunctionModel("自动连接") {
+ Column {
+ val devicesList by viewModel.devices.collectAsState()
+ LazyRow {
+ items(items = devicesList) {
+ Button(onClick = {
+ viewModel.connectToTv(it)
+ }) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ it.serviceName,
+ fontSize = MaterialTheme.typography.bodyLarge.fontSize
+ )
+ Text(
+ it.host.hostAddress + ":" + it.port,
+ fontSize = MaterialTheme.typography.bodyMedium.fontSize
+ )
+ }
+ }
+ }
+ }
+ val context = LocalContext.current
+ MyFuncButtonRow {
+ Button(enabled = !autoSearching, onClick = {
+ viewModel.startDiscovery(context)
+ }) {
+ Text("开始搜索")
+ }
+ Button(enabled = autoSearching, onClick = {
+ viewModel.stopDiscovery()
+ }) {
+ Text("停止搜索")
+ }
+ }
+ }
+ }
+ FunctionModel("手动连接") {
+ Column {
+ TextField(
+ value = ip,
+ onValueChange = { ip = it },
+ label = { Text("设备IP地址(可通过点击电视菜单键查看)") })
+ MyFuncButtonRow {
+ Button(onClick = {
+ viewModel.connectToTvByIp(ip)
+ }) { Text("连接") }
+ Button(onClick = { viewModel.disconnect() }) {
+ Text("断开")
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/ui/compose/ControlScreen.kt b/phone/src/main/java/com/bbitcn/phone/ui/compose/ControlScreen.kt
new file mode 100644
index 0000000..03b26fb
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/ui/compose/ControlScreen.kt
@@ -0,0 +1,92 @@
+package com.bbitcn.phone.ui.compose
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.bbitcn.phone.M
+import com.bbitcn.phone.ui.FunctionModel
+import com.bbitcn.phone.ui.MyFuncButtonRow
+import com.bbitcn.phone.ui.PhoneViewModel
+
+@Composable
+fun ControlScreen(viewModel: PhoneViewModel) {
+ FunctionModel("设备状态") {
+ val device by viewModel.device.collectAsState()
+ if (device == null) {
+ Text("连接已断开")
+ } else {
+ Column {
+ Text("设备名称:${device?.serviceName}")
+ Text("设备地址:${device?.host?.hostAddress}")
+ }
+ }
+ }
+ FunctionModel("基础控制") {
+ MyFuncButtonRow {
+ Button(onClick = {
+ viewModel.prev()
+ }) { Text("后退") }
+ Button(onClick = {
+ viewModel.next()
+ }) { Text("前进") }
+ Button(onClick = {
+ viewModel.reload()
+ }) { Text("刷新") }
+ Button(onClick = {
+ viewModel.stop()
+ }) { Text("停止") }
+ }
+ }
+ FunctionModel("多页控制") {
+ Column {
+ MyFuncButtonRow {
+ Button(onClick = {
+ viewModel.prevPage()
+ }) { Text("上一页") }
+ Button(onClick = {
+ viewModel.nextPage()
+ }) { Text("下一页") }
+ Button(onClick = {
+ viewModel.reloadAll()
+ }) { Text("刷新") }
+ Button(onClick = {
+ viewModel.public()
+ }) { Text("发布") }
+ }
+ }
+ }
+ val devicePagesList by viewModel.devicePagesList.collectAsState()
+ devicePagesList?.let {
+ FunctionModel("当前设备页面") {
+ LazyColumn {
+ items(items = it.pages) {
+ Button(modifier = M.fillMaxWidth(), onClick = {
+ viewModel.switchPage(it.id)
+ }) {
+ Text(
+ modifier = M
+ .fillMaxWidth(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ text = "${it.sort}.${it.name} - ${it.url}",
+ textAlign = TextAlign.Left
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/phone/src/main/java/com/bbitcn/phone/ui/compose/SetScreen.kt b/phone/src/main/java/com/bbitcn/phone/ui/compose/SetScreen.kt
new file mode 100644
index 0000000..bc26d7f
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/ui/compose/SetScreen.kt
@@ -0,0 +1,45 @@
+package com.bbitcn.phone.ui.compose
+
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.input.pointer.pointerInput
+import com.bbitcn.phone.M
+import com.bbitcn.phone.ui.FunctionModel
+import com.bbitcn.phone.ui.PhoneViewModel
+
+@Composable
+fun SetScreen(viewModel: PhoneViewModel) {
+ FunctionModel("页面配置") {
+ val plans by viewModel.plans.collectAsState()
+ LazyColumn {
+ items(items = plans) { plan ->
+ Row(modifier = M.fillMaxWidth()) {
+ Button(
+ modifier = M.weight(1f), onClick = {
+ // 编辑计划
+ viewModel.editPlan(plan)
+ }) { Text(modifier = M.fillMaxWidth(), text = plan.name) }
+ Button(onClick = {
+ viewModel.editPage(plan)
+ }) { Text("编辑细则") }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/ui/theme/Color.kt b/phone/src/main/java/com/bbitcn/phone/ui/theme/Color.kt
new file mode 100644
index 0000000..051e705
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.bbitcn.phone.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)
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/ui/theme/Theme.kt b/phone/src/main/java/com/bbitcn/phone/ui/theme/Theme.kt
new file mode 100644
index 0000000..9b35784
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package com.bbitcn.phone.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun BBITShowTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/ui/theme/Type.kt b/phone/src/main/java/com/bbitcn/phone/ui/theme/Type.kt
new file mode 100644
index 0000000..4275b32
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.bbitcn.phone.ui.theme
+
+import androidx.compose.material3.Typography
+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
+
+// Set of Material typography styles to start with
+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
+ )
+ */
+)
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/utls/MMKVUtil.java b/phone/src/main/java/com/bbitcn/phone/utls/MMKVUtil.java
new file mode 100644
index 0000000..8851557
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/utls/MMKVUtil.java
@@ -0,0 +1,107 @@
+package com.bbitcn.phone.utls;
+
+
+import android.content.Context;
+import android.os.Parcelable;
+
+import com.tencent.mmkv.MMKV;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * MMKV工具类
+ */
+public class MMKVUtil {
+ private static MMKV mmkvInstance;
+
+ private MMKVUtil() {
+ }
+
+ /**
+ * 初始化MMKV,只能在Application中初始化一次
+ */
+ public static void init(Context applicationContext) {
+ MMKV.initialize(applicationContext);
+ mmkvInstance = MMKV.defaultMMKV();
+ }
+
+ /**
+ * 保存数据的方法,我们需要拿到保存数据的具体类型,然后根据类型调用不同的保存方法
+ */
+ public static void put(String key, T object) {
+ if (object instanceof String) {
+ mmkvInstance.encode(key, (String) object);
+ } else if (object instanceof Integer) {
+ mmkvInstance.encode(key, (Integer) object);
+ } else if (object instanceof Boolean) {
+ mmkvInstance.encode(key, (Boolean) object);
+ } else if (object instanceof Float) {
+ mmkvInstance.encode(key, (Float) object);
+ } else if (object instanceof Long) {
+ mmkvInstance.encode(key, (Long) object);
+ } else if (object instanceof Double) {
+ mmkvInstance.encode(key, (Double) object);
+ } else if (object instanceof Set) {
+ mmkvInstance.encode(key, (Set) object);
+ } else if (object instanceof Parcelable) {
+ mmkvInstance.encode(key, (Parcelable) object);
+ } else {
+ mmkvInstance.encode(key, object == null ? "" : object.toString());
+ }
+ }
+
+ /**
+ * 得到保存数据的方法,我们根据默认值得到保存的数据的具体类型,然后调用相对于的方法获取值
+ */
+ public static T get(String key, T defaultObject) {
+ if (defaultObject instanceof String || defaultObject == null) {
+ return (T) mmkvInstance.decodeString(key, (String) defaultObject);
+ } else if (defaultObject instanceof Integer) {
+ return (T) Integer.valueOf(mmkvInstance.decodeInt(key, (Integer) defaultObject));
+ } else if (defaultObject instanceof Boolean) {
+ return (T) Boolean.valueOf(mmkvInstance.decodeBool(key, (Boolean) defaultObject));
+ } else if (defaultObject instanceof Float) {
+ return (T) Float.valueOf(mmkvInstance.decodeFloat(key, (Float) defaultObject));
+ } else if (defaultObject instanceof Long) {
+ return (T) Long.valueOf(mmkvInstance.decodeLong(key, (Long) defaultObject));
+ } else if (defaultObject instanceof Double) {
+ return (T) Double.valueOf(mmkvInstance.decodeDouble(key, (Double) defaultObject));
+ } else if (defaultObject instanceof Parcelable) {
+ Parcelable p = (Parcelable) defaultObject;
+ return (T) mmkvInstance.decodeParcelable(key, p.getClass());
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * 获得字符串
+ */
+ public static String get(String key) {
+ return mmkvInstance.decodeString(key, "");
+ }
+
+ /**
+ * 移除某个key值已经对应的值
+ */
+ public static void remove(String key) {
+ mmkvInstance.remove(key);
+ }
+
+ /**
+ * 查询某个key是否已经存在
+ */
+ public boolean contains(String key) {
+ return mmkvInstance.contains(key);
+ }
+
+ /**
+ * 清除所有数据
+ */
+ public static void clear() {
+ mmkvInstance.clear();
+ }
+
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/utls/PlanTempDatabase.kt b/phone/src/main/java/com/bbitcn/phone/utls/PlanTempDatabase.kt
new file mode 100644
index 0000000..90d7332
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/utls/PlanTempDatabase.kt
@@ -0,0 +1,19 @@
+package com.bbitcn.phone.utls
+
+import com.bbitcn.phone.base.ParameterizedTypeImpl
+import com.bbitcn.phone.model.Plan
+import com.bbitcn.sericulture.base.BaseListTempDataBase
+import kotlin.jvm.java
+import java.lang.reflect.Type
+
+object PlanTempDatabase : BaseListTempDataBase() {
+
+ override fun getKey(): String {
+ return "MENU_PERMISSION_LIST2"
+ }
+
+ override fun getType(): Type {
+ return ParameterizedTypeImpl(Plan::class.java)
+ }
+
+}
\ No newline at end of file
diff --git a/phone/src/main/java/com/bbitcn/phone/utls/PollingTask.kt b/phone/src/main/java/com/bbitcn/phone/utls/PollingTask.kt
new file mode 100644
index 0000000..3a3dd4a
--- /dev/null
+++ b/phone/src/main/java/com/bbitcn/phone/utls/PollingTask.kt
@@ -0,0 +1,160 @@
+package com.bbitcn.phone.utls
+
+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>? = 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 IO任务返回结果的类型
+ */
+ fun startPollingTask(
+ taskId: String,
+ intervalSeconds: Long,
+ ioTask: IRxIOTask,
+ uiTask: IRxUITask
+ ) {
+ 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 {
+ fun doInIOThread(): T
+ }
+
+ // 定义UI任务接口
+ interface IRxUITask {
+ fun doInUIThread(t: T)
+ }
+
+ companion object {
+ private val instances: MutableMap = HashMap()
+
+ @Synchronized
+ fun getInstance(): PollingTask = getInstance("MAIN")
+
+ // 获取基于ID的单例实例
+ @Synchronized
+ fun getInstance(id: String): PollingTask {
+ if (!instances.containsKey(id)) {
+ instances[id] = PollingTask()
+ }
+ return instances[id]!!
+ }
+ }
+}
\ No newline at end of file
diff --git a/phone/src/main/res/mipmap-hdpi/logo.png b/phone/src/main/res/mipmap-hdpi/logo.png
new file mode 100644
index 0000000..de4102a
Binary files /dev/null and b/phone/src/main/res/mipmap-hdpi/logo.png differ
diff --git a/phone/src/main/res/values/colors.xml b/phone/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/phone/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/phone/src/main/res/values/strings.xml b/phone/src/main/res/values/strings.xml
new file mode 100644
index 0000000..d4e99e0
--- /dev/null
+++ b/phone/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ BBIT展示屏控制工具
+
\ No newline at end of file
diff --git a/phone/src/main/res/values/themes.xml b/phone/src/main/res/values/themes.xml
new file mode 100644
index 0000000..6db3f5f
--- /dev/null
+++ b/phone/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/phone/src/test/java/com/bbitcn/phone/ExampleUnitTest.kt b/phone/src/test/java/com/bbitcn/phone/ExampleUnitTest.kt
new file mode 100644
index 0000000..9d9e24f
--- /dev/null
+++ b/phone/src/test/java/com/bbitcn/phone/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.bbitcn.phone
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..98edc6e
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,27 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven {
+ url = uri("https://maven.mozilla.org/maven2/")
+ }
+ }
+}
+
+rootProject.name = "BBITShow"
+include(":tv")
+include(":phone")
diff --git a/tv/.gitignore b/tv/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/tv/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/tv/MyApp.kt b/tv/MyApp.kt
new file mode 100644
index 0000000..e4448e4
--- /dev/null
+++ b/tv/MyApp.kt
@@ -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()
+
+ }
+}
diff --git a/tv/build.gradle.kts b/tv/build.gradle.kts
new file mode 100644
index 0000000..f3e3ffc
--- /dev/null
+++ b/tv/build.gradle.kts
@@ -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")
+}
\ No newline at end of file
diff --git a/tv/proguard-rules.pro b/tv/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/tv/proguard-rules.pro
@@ -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
\ No newline at end of file
diff --git a/tv/src/main/AndroidManifest.xml b/tv/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a6ba2a4
--- /dev/null
+++ b/tv/src/main/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/BaseViewModel.kt b/tv/src/main/java/com/bbitcn/bbitshow/BaseViewModel.kt
new file mode 100644
index 0000000..cbb28d1
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/BaseViewModel.kt
@@ -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 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 doInIoThread(
+ loadingTips: String = "正在加载中",
+ showDialog: Boolean = true,
+ onError: (Throwable) -> Unit = { },
+ onFinish: () -> Unit = { },
+ doInIO: suspend () -> T,
+ ) {
+ doInIoThreadThenUI(loadingTips, showDialog, onError, doInIO, onFinish) { }
+ }
+
+ fun 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()
+
+ /**
+ * 放弃旧任务,执行新任务
+ *
+ * @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 { 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()
+ }
+
+}
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/MyApp.kt b/tv/src/main/java/com/bbitcn/bbitshow/MyApp.kt
new file mode 100644
index 0000000..47a9b5b
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/MyApp.kt
@@ -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()
+ }
+}
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/MyWebSocketServer.kt b/tv/src/main/java/com/bbitcn/bbitshow/MyWebSocketServer.kt
new file mode 100644
index 0000000..b4fae1f
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/MyWebSocketServer.kt
@@ -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) -> Unit
+) : WebSocketServer(address) {
+ override fun onMessage(conn: WebSocket?, message: String?) {
+ message?.let {
+ val json = JSONObject(it)
+ val map = mutableMapOf()
+ 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() {
+ }
+}
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/PollingTask.kt b/tv/src/main/java/com/bbitcn/bbitshow/PollingTask.kt
new file mode 100644
index 0000000..8aa8e88
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/PollingTask.kt
@@ -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>? = 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 IO任务返回结果的类型
+ */
+ fun startPollingTask(
+ taskId: String,
+ intervalSeconds: Long,
+ ioTask: IRxIOTask,
+ uiTask: IRxUITask
+ ) {
+ 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 {
+ fun doInIOThread(): T
+ }
+
+ // 定义UI任务接口
+ interface IRxUITask {
+ fun doInUIThread(t: T)
+ }
+
+ companion object {
+ private val instances: MutableMap = HashMap()
+
+ @Synchronized
+ fun getInstance(): PollingTask = getInstance("MAIN")
+
+ // 获取基于ID的单例实例
+ @Synchronized
+ fun getInstance(id: String): PollingTask {
+ if (!instances.containsKey(id)) {
+ instances[id] = PollingTask()
+ }
+ return instances[id]!!
+ }
+ }
+}
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/TvActivity.kt b/tv/src/main/java/com/bbitcn/bbitshow/TvActivity.kt
new file mode 100644
index 0000000..9b8363d
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/TvActivity.kt
@@ -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()
+ 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()
+ }
+
+}
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/TvViewModel.kt b/tv/src/main/java/com/bbitcn/bbitshow/TvViewModel.kt
new file mode 100644
index 0000000..bac05ea
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/TvViewModel.kt
@@ -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(null)
+ val plan = _plan.asStateFlow()
+ val port = 8080
+
+ /**
+ * 页面列表
+ */
+// private val _pages = MutableStateFlow>(listOf())
+// val pages = _pages.asStateFlow()
+ private val _pages = MutableStateFlow>(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() {}.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 {
+ // 相当于 shouldOverrideUrlLoading
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW)
+ }
+ }
+ val geckoPage = GeckoPage(id = page.id, session = session)
+ _pages.update { current -> current + geckoPage }
+ }
+ _currentIndex.value = 0
+ }
+
+}
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/base/BaseTempDataBase.kt b/tv/src/main/java/com/bbitcn/bbitshow/base/BaseTempDataBase.kt
new file mode 100644
index 0000000..74d09d5
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/base/BaseTempDataBase.kt
@@ -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 {
+
+ 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() 异步提交
+ }
+
+}
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/base/ParameterizedTypeImpl.kt b/tv/src/main/java/com/bbitcn/bbitshow/base/ParameterizedTypeImpl.kt
new file mode 100644
index 0000000..744fa22
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/base/ParameterizedTypeImpl.kt
@@ -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 {
+ return arrayOf(clazz)
+ }
+
+ override fun getRawType(): Type {
+ return MutableList::class.java
+ }
+
+ override fun getOwnerType(): Type? {
+ return null
+ }
+}
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/model/GeckoPage.kt b/tv/src/main/java/com/bbitcn/bbitshow/model/GeckoPage.kt
new file mode 100644
index 0000000..55defe7
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/model/GeckoPage.kt
@@ -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
+)
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/model/Page.kt b/tv/src/main/java/com/bbitcn/bbitshow/model/Page.kt
new file mode 100644
index 0000000..d220630
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/model/Page.kt
@@ -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
+)
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/model/Plan.kt b/tv/src/main/java/com/bbitcn/bbitshow/model/Plan.kt
new file mode 100644
index 0000000..06d0032
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/model/Plan.kt
@@ -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
+
+)
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/ui/theme/Color.kt b/tv/src/main/java/com/bbitcn/bbitshow/ui/theme/Color.kt
new file mode 100644
index 0000000..f05921d
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/ui/theme/Color.kt
@@ -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)
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/ui/theme/Theme.kt b/tv/src/main/java/com/bbitcn/bbitshow/ui/theme/Theme.kt
new file mode 100644
index 0000000..f1c408d
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/ui/theme/Theme.kt
@@ -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
+ )
+}
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/ui/theme/Type.kt b/tv/src/main/java/com/bbitcn/bbitshow/ui/theme/Type.kt
new file mode 100644
index 0000000..fe84f9a
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/ui/theme/Type.kt
@@ -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
+ )
+ */
+)
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/utls/MyUtils.kt b/tv/src/main/java/com/bbitcn/bbitshow/utls/MyUtils.kt
new file mode 100644
index 0000000..58b89c9
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/utls/MyUtils.kt
@@ -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()
+ map["type"] = "tv"
+ map["data"] = gson.toJson(PlanTempDB.getData())
+ return JSONObject(map).toString()
+ }
+}
\ No newline at end of file
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/utls/PlanTempDB.kt b/tv/src/main/java/com/bbitcn/bbitshow/utls/PlanTempDB.kt
new file mode 100644
index 0000000..44ad3c8
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/utls/PlanTempDB.kt
@@ -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(){
+ 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() {}.type
+ }
+
+}
diff --git a/tv/src/main/java/com/bbitcn/bbitshow/utls/SPUtils.kt b/tv/src/main/java/com/bbitcn/bbitshow/utls/SPUtils.kt
new file mode 100644
index 0000000..63c4f00
--- /dev/null
+++ b/tv/src/main/java/com/bbitcn/bbitshow/utls/SPUtils.kt
@@ -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()
+ }
+}
diff --git a/tv/src/main/res/mipmap-hdpi/logo.png b/tv/src/main/res/mipmap-hdpi/logo.png
new file mode 100644
index 0000000..de4102a
Binary files /dev/null and b/tv/src/main/res/mipmap-hdpi/logo.png differ
diff --git a/tv/src/main/res/values/strings.xml b/tv/src/main/res/values/strings.xml
new file mode 100644
index 0000000..5b540d6
--- /dev/null
+++ b/tv/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ BBIT展示屏
+
\ No newline at end of file
diff --git a/tv/src/main/res/values/themes.xml b/tv/src/main/res/values/themes.xml
new file mode 100644
index 0000000..3b041b1
--- /dev/null
+++ b/tv/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file