diff --git a/.github/workflows/github-action-pre_release.yml b/.github/workflows/github-action-pre_release.yml new file mode 100644 index 0000000..3d2d86d --- /dev/null +++ b/.github/workflows/github-action-pre_release.yml @@ -0,0 +1,121 @@ +name: TV-Multiplatform Build Pre Release +on: + push: + branches: [pre-release] +env: + tag: ${{ github.run_id }} +jobs: + build-win: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + cache: 'gradle' + - run: ls +# - run: ./gradlew packageReleaseDistributionForCurrentOS + - run: ./gradlew createReleaseDistributable + - run: ls && tree ./desktopApp /f + - name: compress + shell: pwsh + run: Compress-Archive -Path "./composeApp/build/compose/binaries/main-release/app/TV" -DestinationPath ./TV-win-$Env:tag.zip +# - name: move result to root +# shell: pwsh +# run: Move-Item -Path "./composeApp/build/compose/binaries/main-release/app/*.zip" -Destination "./" && Move-Item -Path "./composeApp/build/compose/binaries/main-release/msi/*.msi" -Destination "./" + - run: ls + - uses: actions/upload-artifact@v4 + with: + name: win-file + path: | + *.msi + *.zip + build-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + cache: 'gradle' + - run: ls + - run: chmod +x ./gradlew && ./gradlew createReleaseDistributable + - run: ./gradlew packageReleaseDistributionForCurrentOS + - run: ls && tree + - name: compress + run: cd ./composeApp/build/compose/binaries/main-release/app && zip -q -r ./TV-linux-$tag.zip ./TV + - name: move file + run: mv ./composeApp/build/compose/binaries/main-release/deb/*.deb ./TV-linux-$tag.deb && mv ./composeApp/build/compose/binaries/main-release/app/*.zip ./ + - run: ls + - uses: actions/upload-artifact@v4 + with: + name: linux-file + path: | + *.deb + *.zip + # https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Signing_and_notarization_on_macOS/README.md + build-mac: + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + cache: 'gradle' + - run: ls + - run: chmod +x ./gradlew && ./gradlew createReleaseDistributable +# - run: ./gradlew packageReleaseDistributionForCurrentOS + - run: ls + - name: compress + run: zip -q -r ./TV-mac-$tag.zip ./composeApp/build/compose/binaries/main-release/app +# - name: move file +# run: mv ./composeApp/build/compose/binaries/main-release/dmg/*.dmg ./TV-mac-$tag.dmg + - run: ls + - uses: actions/upload-artifact@v4 + with: + name: mac-file + path: | + *.dmg + *.zip + release: + runs-on: ubuntu-latest + needs: [ build-win, build-linux, build-mac] + steps: + - name: Download all workflow run artifacts + uses: actions/download-artifact@v4 + - run: ls && tree + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: | + **/*.zip + **/*.deb + **/*.pkg + **/*.dmg + **/*.msi + tag_name: ${{env.tag}} + prerelease: true + permissions: + contents: write + sendMsg: + name: sendMsg + runs-on: ubuntu-latest + needs: + - release + steps: + - uses: colutius/Telegram-Msg@main + with: + token: ${{ secrets.TG_TOKEN }} + chatid: ${{ secrets.TG_GROUP_ID }} + message: | + 👇新的预发布👇 + + 📦仓库: ${{ github.repository }} + button: true + button_name: 👀下载👀 + button_url: https://github.com/${{ github.repository }}/releases/tag/${{env.tag}} + is_notify: true + is_preview: true \ No newline at end of file diff --git a/README.md b/README.md index 212f402..6527318 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,14 @@ ![](readme_images/home.png) ## 搜索 ![](readme_images/search.png) +## 搜索结果页 +![](readme_images/search_result.png) ## 历史记录 ![](readme_images/history.png) # 讨论群 -[TG](https://t.me/tv_multiplatform) \ No newline at end of file +[TG](https://t.me/tv_multiplatform) + +# 引用 +player: https://github.com/numq/jetpack-compose-desktop-media-player \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index e739be8..ae3abcb 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -24,9 +24,9 @@ kotlin { val desktopMain by getting commonMain.dependencies { - val ktorVer = "2.3.8" + val ktorVer = "2.3.12" val logbackVer = "1.3.14" - val imageLoader = "1.7.4" + val imageLoader = "1.8.1" val hutoolVer = "5.8.27" // val kotlinVersion = extra["kotlin.version"] as String implementation(compose.runtime) @@ -66,8 +66,8 @@ kotlin { implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVer") implementation("io.ktor:ktor-server-swagger:$ktorVer") implementation("io.ktor:ktor-client-core:$ktorVer") - implementation("io.ktor:ktor-client-cio:$ktorVer") - implementation("io.ktor:ktor-server-call-logging:$ktorVer") + implementation("io.ktor:ktor-client-okhttp:$ktorVer") +// implementation("io.ktor:ktor-server-call-logging:$ktorVer") // log implementation("ch.qos.logback:logback-classic:$logbackVer") @@ -76,7 +76,7 @@ kotlin { // optional - Moko Resources Decoder // api("io.github.qdsfdhvh:image-loader-extension-moko-resources:$imageLoader") - api(project.dependencies.platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.12")) + api(project.dependencies.platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.14")) api("com.squareup.okhttp3:okhttp") api("com.squareup.okhttp3:okhttp-dnsoverhttps") @@ -132,23 +132,22 @@ compose.desktop { "jdk.httpserver", "jdk.unsupported" ) - val dir = project.layout.projectDirectory.dir("/src/desktopMain/resources/res") + val dir = project.layout.projectDirectory.dir("src/desktopMain/resources/res") println(dir) - appResourcesRootDir.set(project.layout.projectDirectory.dir("/src/desktopMain/resources/res")) + appResourcesRootDir.set(project.layout.projectDirectory.dir("src/desktopMain/resources/res")) // app icons https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials/Native_distributions_and_local_execution#app-icon windows { - iconFile.set(project.file("src/commonMain/composeResources/icon/icon-s.ico")) + iconFile.set(project.file("src/commonMain/resources/pic/icon-s.ico")) dirChooser = true upgradeUuid = "161FA5A0-A30B-4568-9E84-B3CD637CC8FE" } linux { - iconFile.set(project.file("src/commonMain/composeResources/icon/TV-icon-s.png")) - + iconFile.set(project.file("src/commonMain/resources/pic/TV-icon-s.png")) } macOS { - iconFile.set(project.file("src/commonMain/composeResources/icon/icon.icns")) + iconFile.set(project.file("src/commonMain/resources/pic/icon.icns")) } } diff --git a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml index c0bcfb2..d7bf795 100644 --- a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml +++ b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml @@ -33,4 +33,4 @@ android:fillColor="#00000000" android:strokeColor="#083042" android:fillType="nonZero"/> - \ No newline at end of file + diff --git a/composeApp/src/commonMain/composeResources/icon/compose-multiplatform.xml b/composeApp/src/commonMain/composeResources/icon/compose-multiplatform.xml deleted file mode 100644 index d7bf795..0000000 --- a/composeApp/src/commonMain/composeResources/icon/compose-multiplatform.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - diff --git a/composeApp/src/commonMain/kotlin/com/corner/bean/Hot.kt b/composeApp/src/commonMain/kotlin/com/corner/bean/Hot.kt index c05cf3a..d57bfe2 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/bean/Hot.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/bean/Hot.kt @@ -10,28 +10,33 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.decodeFromStream import okhttp3.Headers.Companion.toHeaders +import okhttp3.Response +import org.slf4j.LoggerFactory + +private val log = LoggerFactory.getLogger("Hot") @Serializable -data class Hot(val data:List) { - companion object{ +data class Hot(val data: List) { + companion object { @OptIn(ExperimentalSerializationApi::class) public fun getHotList() { SiteViewModel.viewModelScope.launch { - val response = Http.Get("https://api.web.360kan.com/v1/rank?cat=1", headers = mapOf(HttpHeaders.Referrer to "https://www.360kan.com/rank/general").toHeaders()).execute() -// val response = KtorClient.client.get("https://api.web.360kan.com/v1/rank?cat=1") { -// headers { -// set(HttpHeaders.Referrer, "https://www.360kan.com/rank/general") -// } -// } + var response: Response? = null + try { + response = Http.Get( + "https://api.web.360kan.com/v1/rank?cat=1", + headers = mapOf(HttpHeaders.Referrer to "https://www.360kan.com/rank/general").toHeaders() + ).execute() + } catch (e: Exception) { + log.error("请求热搜失败", e) + } - if(response.isSuccessful) + if (response?.isSuccessful == true) GlobalModel.hotList.value = Jsons.decodeFromStream(response.body.byteStream()).data -// GlobalModel.hotList.update { it. = Jsons.decodeFromStream(response.bodyAsChannel().toInputStream()).data} -// GlobalModel.hotList.value.addAll() } } } } @Serializable -data class HotData(val title:String, val comment:String, val upinfo:String, val description:String) \ No newline at end of file +data class HotData(val title: String, val comment: String, val upinfo: String, val description: String) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/bean/Setting.kt b/composeApp/src/commonMain/kotlin/com/corner/bean/Setting.kt index 47613c4..785d264 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/bean/Setting.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/bean/Setting.kt @@ -23,20 +23,44 @@ class SearchHistoryCache:Cache{ private val maxSize:Int = 30 - private var searchHistoryList:MutableSet = mutableSetOf() + private var searchHistoryList:LinkedHashSet = linkedSetOf() override fun getName(): String { return "searchHistory" } override fun add(t:String) { if(searchHistoryList.size >= maxSize){ - searchHistoryList = searchHistoryList.drop(1).toMutableSet() + val list:LinkedHashSet = linkedSetOf() + list.addAll(searchHistoryList.drop(1)) + searchHistoryList = list } + searchHistoryList.remove(t) searchHistoryList.add(t) } - fun getSearchList():Set{ - return searchHistoryList + fun getSearchList():List{ + return searchHistoryList.reversed() + } + +} + +@Serializable +class PlayerStateCache:Cache{ + private val map:MutableMap = mutableMapOf(); + override fun getName(): String { + return "playerState" + } + + override fun add(t: String) { + + } + + fun add(key:String, value: String){ + map.put(key,value) + } + + fun get(key: String):String?{ + return map.get(key) } } @@ -59,8 +83,8 @@ enum class SettingType(val id: String) { object SettingStore { private val defaultList = listOf( Setting("vod", "点播", ""), - Setting("player", "外部播放器", ""), - Setting("log", "日志级别", Level.DEBUG.levelStr) + Setting("log", "日志级别", Level.DEBUG.levelStr), + Setting("player", "播放器", "false#") ) private var settingFile = SettingFile(mutableListOf(), mutableMapOf()) @@ -79,6 +103,12 @@ object SettingStore { return settingFile.list } + fun reset(){ + settingFile = SettingFile(mutableListOf(), mutableMapOf()) + initSetting() + write() + } + fun write() { Files.write(Paths.setting(), Jsons.encodeToString(settingFile).toByteArray()) } @@ -88,8 +118,9 @@ object SettingStore { write() } - fun setCache(name:String, value: String){ - settingFile.cache[name]?.add(value) + fun doWithCache(func:(MutableMap) -> Unit){ + func(settingFile.cache) + write() } fun getCache(name:String): Cache? { @@ -120,7 +151,7 @@ object SettingStore { } val cache = getCache(SettingType.SEARCHHISTORY.id) if (cache != null) { - return (cache as SearchHistoryCache).getSearchList() + return (cache as SearchHistoryCache).getSearchList().toSet() } return setOf() } diff --git a/composeApp/src/commonMain/kotlin/com/corner/bean/Suggest.kt b/composeApp/src/commonMain/kotlin/com/corner/bean/Suggest.kt index 52c4f53..5267b53 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/bean/Suggest.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/bean/Suggest.kt @@ -3,11 +3,16 @@ package com.corner.bean import com.corner.catvodcore.util.Jsons import kotlinx.serialization.Serializable +@Serializable +data class Data ( + val name: String = "" +) + @Serializable -class Suggest { +data class Suggest( var data: List? = null - +){ fun isEmpty(): Boolean { return data.isNullOrEmpty() } @@ -16,27 +21,6 @@ class Suggest { data = null } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Suggest - - return data == other.data - } - - override fun hashCode(): Int { - var result = 0 - data?.forEach { result = 31 * result + it.hashCode() } - return result - } - - - @Serializable - data class Data ( - val name: String = "" - ) - companion object { fun objectFrom(str: String): Suggest { return Jsons.decodeFromString(str) diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Episode.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Episode.kt index cd51c80..d99a979 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Episode.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Episode.kt @@ -1,26 +1,46 @@ package com.corner.catvodcore.bean +import com.corner.catvodcore.util.Utils import com.google.gson.annotations.SerializedName import kotlinx.serialization.Serializable +import java.util.* @Serializable data class Episode( @SerializedName("name") - var name: String? = null, + var name: String = "", @SerializedName("desc") val desc: String? = null, @SerializedName("url") - var url: String? = null, - val number: Int = 0, + var url: String = "", + var number: Int = 0, var activated: Boolean = false, val selected: Boolean = false ) { companion object { fun create(name: String, string: String): Episode { val episode = Episode() + episode.number = Utils.getDigit(name) episode.name = name episode.url = string return episode } + + } + + fun rule1(name: String?): Boolean { + return this.name.equals(name, ignoreCase = true) + } + + fun rule2(number: Int): Boolean { + return this.number == number && number != -1 + } + + fun rule3(name: String): Boolean { + return this.name.lowercase(Locale.getDefault()).contains(name.lowercase(Locale.getDefault())) + } + + fun rule4(name: String): Boolean { + return this.name.lowercase(Locale.getDefault()).contains(name.lowercase(Locale.getDefault())) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Flag.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Flag.kt index b0c90cf..a714a8a 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Flag.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Flag.kt @@ -1,5 +1,6 @@ package com.corner.catvodcore.bean +import com.corner.catvodcore.util.Utils import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import java.util.* @@ -30,10 +31,22 @@ data class Flag ( val episode = if(split.size > 1) Episode.create(if(split[0].isEmpty()) number else split[0].trim(), split[1]) else Episode.create(number, urls[i]) if(!episodes.contains(episode)) episodes.add(episode) } - episodes.sortWith(Comparator { o1, o2 -> - o1.name?.compareTo(o2.name ?: "")!! - }) + episodes.sortBy { it.number } +// episodes.sortWith(Comparator { o1, o2 -> +// o1.name.compareTo(o2.name) +// }) + } + fun find(remarks: String, strict: Boolean): Episode? { + val number: Int = Utils.getDigit(remarks) + if (episodes.size == 0) return null + if (episodes.size == 1) return episodes.get(0) + for (item in episodes) if (item.rule1(remarks)) return item + for (item in episodes) if (item.rule2(number)) return item + if (number == -1) for (item in episodes) if (item.rule3(remarks)) return item + if (number == -1) for (item in episodes) if (item.rule4(remarks)) return item + if (position != -1) return episodes[position] + return if (strict) null else episodes[0] } fun isEmpty():Boolean{ diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Result.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Result.kt index 503011d..e5cb14e 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Result.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Result.kt @@ -9,13 +9,9 @@ import kotlinx.serialization.Transient @Serializable class Result { - // @Path("class") -// @ElementList(entry = "ty", required = false, inline = true) @SerialName("class") var types: MutableList = mutableListOf() - // @Path("list") -// @ElementList(entry = "video", required = false, inline = true) var list: MutableList = mutableListOf() val filters: MutableMap> = mutableMapOf() diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Type.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Type.kt index f8d2aa3..64ee0be 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Type.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Type.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Transient @Serializable class Type { - constructor(id:String, name:String){ + constructor(id: String, name: String) { this.typeId = id this.typeName = name } @@ -19,13 +19,13 @@ class Type { var typeName: String = "" @Transient - var selected:Boolean = false + var selected: Boolean = false @Transient - var failTime:Int =0 + var failTime: Int = 0 - companion object{ - fun home():Type{ + companion object { + fun home(): Type { val type = Type("home", "推荐") type.selected = true return type @@ -41,6 +41,7 @@ class Type { if (typeId != other.typeId) return false if (typeName != other.typeName) return false if (failTime != other.failTime) return false + if (selected != other.selected) return false return true } @@ -49,6 +50,7 @@ class Type { var result = typeId.hashCode() result = 31 * result + typeName.hashCode() result = 31 * result + failTime.hashCode() + result = 31 * result + selected.hashCode() return result } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Url.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Url.kt index 10c142a..9645180 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Url.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Url.kt @@ -5,21 +5,20 @@ import kotlinx.serialization.json.* import kotlinx.serialization.serializer @Serializable -class Url { - val values: MutableList = mutableListOf() - val position = 0 -} +data class Url ( + val values: MutableList = mutableListOf(), + val position:Int = 0 +) fun Url.v():String{ - return if (position >= (values.size)) "" else values.get(position).v ?: "" + return if (position >= (values.size)) "" else values[position].v ?: "" } fun Url.replace(url:String){ - values.get(position).v = url + values[position].v = url } fun Url.add(url:String):Url{ -// if(StringUtils.isBlank(url)) return values.add(Value("",url)) return this } @@ -52,29 +51,4 @@ object UrlSerializable: JsonTransformingSerializer(serializer()) { override fun transformSerialize(element: JsonElement): JsonElement { return super.transformSerialize(element) } -} - -//object UrlSerialize: KSerializer{ -// override val descriptor: SerialDescriptor -// get() = buildClassSerialDescriptor("Url"){ -// element("values") -// element("position") -// } -// -// override fun deserialize(decoder: Decoder): Url { -// decoder.decodeStructure(descriptor){ -// val values = mutableListOf() -// val position = 0 -// while(true){ -// when(val index = decodeElementIndex(descriptor)){ -// 0 -> decoder.decode -// } -// } -// } -// } -// -// override fun serialize(encoder: Encoder, value: Url) { -// TODO("Not yet implemented") -// } -// -//} \ No newline at end of file +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Vod.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Vod.kt index ab71c46..0d17c36 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Vod.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/bean/Vod.kt @@ -2,6 +2,8 @@ package com.corner.catvod.enum.bean import com.corner.catvodcore.bean.Episode import com.corner.catvodcore.bean.Flag +import com.corner.database.History +import com.corner.util.Constants import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -36,10 +38,12 @@ data class Vod( var subEpisode: MutableList? = mutableListOf(), @Transient var currentTabIndex: Int = 0, - @Transient - var version: Int = 0 ) { companion object { + + fun Vod.getEpisode():Episode?{ + return subEpisode?.find { it.activated } + } fun Vod.isEmpty():Boolean{ return org.apache.commons.lang3.StringUtils.isBlank(vodId) || vodFlags.isEmpty() } @@ -74,8 +78,8 @@ data class Vod( fun List.getPage(index: Int): MutableList { val list = this.subList( - index * 15, - if (index * 15 + 15 > size) size else index * 15 + 15 + index * Constants.EpSize, + if (index * Constants.EpSize + Constants.EpSize > size) size else index * Constants.EpSize + Constants.EpSize ).toMutableList() return list } @@ -84,6 +88,32 @@ data class Vod( fun isFolder():Boolean{ return VodTag.Folder.called == vodTag } + + fun findAndSetEpByName(history: History): Episode? { + if (history.vodRemarks.isNullOrBlank()) return null + currentFlag = vodFlags.find { it?.flag == history.vodFlag } + val episode = currentFlag?.find(history.vodRemarks, true) + if(episode != null){ + episode.activated = true + val indexOf = currentFlag?.episodes?.indexOf(episode) + // 32 15 16 + currentTabIndex = (indexOf?.plus(1))!! / Constants.EpSize + subEpisode = currentFlag?.episodes?.getPage(currentTabIndex)!! + } + return episode + } + + fun nextFlag():Flag?{ + val find = vodFlags.find { it?.activated ?: false } + val indexOf = vodFlags.indexOf(find) + if(indexOf + 1 >= vodFlags.size) return null + val flag = vodFlags[indexOf + 1] + vodFlags.forEach{ + it?.activated = flag?.flag == it?.flag + } +// flag.episodes.indexOf() + return flag + } } enum class VodTag(val called: String) { diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/config/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/config/ApiConfig.kt index 14ccb96..afbc39c 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/config/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/config/ApiConfig.kt @@ -32,6 +32,8 @@ object ApiConfig{ log.info("parseConfig start cfg:{} isJson:{}", cfg, isJson) val data = getData(if (isJson) cfg.json ?: "" else cfg.url!!, isJson) ?: throw RuntimeException("配置读取异常") if(StringUtils.isBlank(data)) { + log.warn("配置数据为空") + SnackBar.postMsg("配置数据为空 请检查") setHome(null) return api } @@ -155,8 +157,4 @@ fun Api.initSite() { GlobalModel.home.value = sites.first() Db.Config.setHome(url, ConfigType.SITE.ordinal, GlobalModel.home.value.toString()) } -} - -fun proxyLocal() { - } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Http.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Http.kt index 0778f11..71c934d 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Http.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Http.kt @@ -10,10 +10,14 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.dnsoverhttps.DnsOverHttps import java.net.http.HttpClient +import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.X509Certificate import java.time.Duration import java.time.temporal.ChronoUnit import java.util.* import java.util.concurrent.TimeUnit +import javax.net.ssl.* class Http { companion object { @@ -21,11 +25,65 @@ class Http { private var client: OkHttpClient? = null private var selector: ProxySelect? = null private val defaultHeaders: Headers = Headers.Builder().build() + + //获取这个SSLSocketFactory + fun getSSLSocketFactory(): SSLSocketFactory { + try { + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, getTrustManager(), SecureRandom()) + return sslContext.socketFactory + } catch (e: Exception) { + throw RuntimeException(e) + } + } + + //获取TrustManager + private fun getTrustManager(): Array { + return arrayOf(object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) { + } + + override fun checkServerTrusted(chain: Array, authType: String) { + } + + override fun getAcceptedIssuers(): Array { + return arrayOf() + } + } + ) + } + + //获取HostnameVerifier + fun getHostnameVerifier(): HostnameVerifier { + return HostnameVerifier { s: String?, sslSession: SSLSession? -> true } + } + + fun getX509TrustManager(): X509TrustManager? { + var trustManager: X509TrustManager? = null + try { + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + val trustManagers = trustManagerFactory.trustManagers + check(!(trustManagers.size != 1 || trustManagers[0] !is X509TrustManager)) { "Unexpected default trust managers:" + trustManagers.contentToString() } + trustManager = trustManagers[0] as X509TrustManager + } catch (e: Exception) { + e.printStackTrace() + } + + return trustManager + } private val builder: OkHttpClient.Builder - get() = OkHttpClient().newBuilder().connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS).writeTimeout(10, TimeUnit.SECONDS) - .followRedirects(true) - .dns(dns()) + get(){ + val dispatcher = Dispatcher() + return OkHttpClient().newBuilder().connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS).writeTimeout(10, TimeUnit.SECONDS) + .followRedirects(true) + .sslSocketFactory(getSSLSocketFactory(), getX509TrustManager()!!) + .hostnameVerifier((getHostnameVerifier())) +// .callTimeout(Duration.of(3, ChronoUnit.SECONDS)) + .dispatcher(dispatcher) + .dns(dns()) + } fun dns(): Dns { diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Jsons.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Jsons.kt index 1d6dd40..77f2d9b 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Jsons.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Jsons.kt @@ -1,12 +1,16 @@ package com.corner.catvodcore.util +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.* +@OptIn(ExperimentalSerializationApi::class) val Jsons = Json { ignoreUnknownKeys = true isLenient = true + prettyPrint = true + prettyPrintIndent = " " } diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/KtorClient.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/KtorClient.kt index 83cac90..f547da2 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/KtorClient.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/KtorClient.kt @@ -1,10 +1,10 @@ package com.corner.catvodcore.util import io.ktor.client.* -import io.ktor.client.engine.cio.* +import io.ktor.client.engine.okhttp.* class KtorClient { companion object{ - val client = HttpClient(CIO) + val client = HttpClient(OkHttp) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/KtorHeaderUrlFetcher.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/KtorHeaderUrlFetcher.kt index 2223f69..18c580d 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/KtorHeaderUrlFetcher.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/KtorHeaderUrlFetcher.kt @@ -5,55 +5,54 @@ import androidx.compose.ui.res.loadImageBitmap import com.seiko.imageloader.component.fetcher.FetchResult import com.seiko.imageloader.component.fetcher.Fetcher import com.seiko.imageloader.option.Options +import io.ktor.client.* +import io.ktor.client.engine.okhttp.* +import io.ktor.client.request.* +import io.ktor.client.statement.* import io.ktor.http.* +import io.ktor.util.* +import io.ktor.utils.io.jvm.javaio.* import kotlinx.serialization.json.jsonObject -import okhttp3.Headers.Companion.toHeaders -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response class KtorHeaderUrlFetcher private constructor( private val httpUrl: String, - httpClient: () -> OkHttpClient, + httpClient: () -> HttpClient, ) : Fetcher { private val httpClient by lazy(httpClient) override suspend fun fetch(): FetchResult { - val url = httpUrl.toString() - val headers = mutableMapOf() - headers.run { - if (url.contains("@Headers=")) { - val elements = Jsons.parseToJsonElement(url.split("@Headers=")[1].split("@")[0]) - for (jsonElement in elements.jsonObject) { - put(jsonElement.key, jsonElement.value.toString()) + var url = httpUrl.toString() + val response = httpClient.request { + headers { + if (url.contains("@Headers=")) { + appendAll(StringValues.build { + val elements = Jsons.parseToJsonElement(url.split("@Headers=").apply { url = this[0] }[1].split("@")[0]) + for (jsonElement in elements.jsonObject) { + append(jsonElement.key, jsonElement.value.toString()) + } + }) + } + if (url.contains("@Cookie=")) append(HttpHeaders.Cookie, url.split("@Cookie=").apply { url = this[0] }[1].split("@")[0]) + if (url.contains("@Referer=")) append(HttpHeaders.Referrer, url.split("@Referer=").apply { url = this[0] }[1].split("@")[0]) + if (url.contains("@User-Agent=")) append(HttpHeaders.UserAgent, url.split("@User-Agent=").apply { url = this[0] }[1].split("@")[0]) + } + url(url) } - if (url.contains("@Cookie=")) put(HttpHeaders.Cookie, url.split("@Cookie=")[1].split("@")[0]) - if (url.contains("@Referer=")) put(HttpHeaders.Referrer, url.split("@Referer=")[1].split("@")[0]) - if (url.contains("@User-Agent=")) put(HttpHeaders.UserAgent, url.split("@User-Agent=")[1].split("@")[0]) - } - - - val request = Request.Builder().url(url.split("@")[0]).headers(headers.toHeaders()).build() - var response:Response? = null - try { - response = httpClient.newCall(request).execute() - if (response.isSuccessful) { + if (response.status.isSuccess()) { val ofSource = FetchResult.OfPainter( - painter = BitmapPainter(loadImageBitmap(response.body.byteStream())) + painter = BitmapPainter(loadImageBitmap(response.bodyAsChannel().toInputStream())) ) return ofSource } - }finally { - response?.close() - } - throw RuntimeException("code:${response?.code}, ${HttpStatusCode.fromValue(response?.code ?: 500)}") + throw RuntimeException("code:${response.status.value}, ${response.status.description}") } + class Factory( - private val httpClient: () -> OkHttpClient, + private val httpClient: () -> HttpClient, ) : Fetcher.Factory { override fun create(data: Any, options: Options): Fetcher? { if (data is String) return KtorHeaderUrlFetcher(data, httpClient) @@ -62,11 +61,9 @@ class KtorHeaderUrlFetcher private constructor( } companion object { - val defaultHttpEngineFactory: () -> OkHttpClient - get() = { Http.client() } val CustomUrlFetcher = Factory { - Http.client() + HttpClient(OkHttp) } } } diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Paths.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Paths.kt index 9b7d93c..444dfaf 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Paths.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Paths.kt @@ -8,7 +8,7 @@ import java.io.File import java.nio.file.Path object Paths { - private val runPath = System.getProperty("user.dir") +// private val runPath = System.getProperty("user.dir") private val classPath = System.getProperty("java.class.path") private val ApplicationName = "TV-Multiplatform" private val log = LoggerFactory.getLogger("Paths") @@ -32,7 +32,7 @@ object Paths { } fun root():File{ - return File(runPath).resolve("data") + return userDataDir.resolve("data") } fun userDataRoot():File{ @@ -88,8 +88,8 @@ object Paths { return file.toPath() } - fun log(): File { - return root().check().resolve("log.txt") + fun playerLog(): File { + return root().check().resolve("playerLog.txt") } fun logPath():File{ diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Utils.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Utils.kt index 8338d6e..611c56f 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Utils.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/util/Utils.kt @@ -1,8 +1,13 @@ package com.corner.catvodcore.util +import com.corner.catvodcore.config.ApiConfig +import com.corner.database.Db import org.apache.commons.lang3.StringUtils import java.math.BigInteger import java.security.MessageDigest +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder import java.util.* object Utils { @@ -29,6 +34,34 @@ object Utils { return Base64.getEncoder().encodeToString(bytes) } + fun getDigit(text: String): Int { + try { + if (text.startsWith("上") || text.startsWith("下")) return -1 + return text.replace("(?i)(mp4|H264|H265|720p|1080p|2160p|4K)".toRegex(), "").replace("\\D+".toRegex(), "") + .toInt() + } catch (e: java.lang.Exception) { + return -1 + } + } + + fun getHistoryKey(key:String, id:String): String { + return key + Db.SYMBOL + id + Db.SYMBOL + ApiConfig.api.cfg.value?.id!! + } + + fun formatMilliseconds(milliseconds: Long): String { + val seconds = (milliseconds / 1000) % 60 + val minutes = (milliseconds / (1000 * 60)) % 60 + val hours = (milliseconds / (1000 * 60 * 60)) % 24 + val days = milliseconds / (1000 * 60 * 60 * 24) + + return if (days > 0) { + "%d天 %02d:%02d:%02d".format(days, hours, minutes, seconds) + }else if(hours > 0){ + "%02d:%02d:%02d".format(hours, minutes, seconds) + }else{ + "%02d:%02d".format(minutes, seconds) + } + } // fun parse diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/viewmodel/GlobalModel.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/viewmodel/GlobalModel.kt index d1ac3a7..e2ce028 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/viewmodel/GlobalModel.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/viewmodel/GlobalModel.kt @@ -1,20 +1,39 @@ package com.corner.catvodcore.viewmodel import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowState import com.arkivanov.decompose.value.MutableValue import com.corner.bean.HotData import com.corner.catvod.enum.bean.Site import com.corner.catvod.enum.bean.Vod +import com.corner.ui.scene.SnackBar object GlobalModel { + var windowState:WindowState? = null val hotList = MutableValue(listOf()) val chooseVod = mutableStateOf(Vod()) var detailFromSearch = false val home = MutableValue(Site.get("","")) val clear = MutableValue(false) var keyword = MutableValue("") - + var videoFullScreen = MutableValue(false) + private set fun clearHome(){ home.value = Site.get("","") } + + fun toggleVideoFullScreen():Boolean{ + toggleWindowFullScreen() + videoFullScreen.value = !videoFullScreen.value + return videoFullScreen.value + } + + private fun toggleWindowFullScreen(){ + if(windowState?.placement == WindowPlacement.Fullscreen){ + windowState?.placement = WindowPlacement.Floating + }else{ + windowState?.placement = WindowPlacement.Fullscreen + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/viewmodel/SiteViewModel.kt b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/viewmodel/SiteViewModel.kt index ecff01d..c9e829e 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/catvodcore/viewmodel/SiteViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/catvodcore/viewmodel/SiteViewModel.kt @@ -18,6 +18,7 @@ import io.ktor.http.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren import kotlinx.serialization.encodeToString import okhttp3.Headers.Companion.toHeaders import org.apache.commons.lang3.StringUtils @@ -34,7 +35,11 @@ object SiteViewModel { val quickSearch: MutableState> = mutableStateOf(CopyOnWriteArrayList(listOf(Collect.all()))) private val supervisorJob = SupervisorJob() - val viewModelScope = CoroutineScope(Dispatchers.Default + supervisorJob) + val viewModelScope = CoroutineScope(Dispatchers.IO + supervisorJob) + + fun cancelAll(){ + supervisorJob.cancelChildren() + } fun getSearchResultActive(): Collect { return search.value.first { it.isActivated().value } @@ -54,7 +59,7 @@ object SiteViewModel { if ((rst.list.size) > 0) result.value = rst val homeVideoContent = spider.homeVideoContent() SpiderDebug.log("homeContent: $homeVideoContent") - rst.list.addAll(Jsons.decodeFromString(homeContent).list) + rst.list.addAll(Jsons.decodeFromString(homeVideoContent).list) result.value = rst.also { this.result.value = it } } @@ -83,7 +88,7 @@ object SiteViewModel { fun detailContent(key: String, id: String): Result? { val site: Site = ApiConfig.api.sites.find { it.key == key } ?: return null - var rst:Result = Result() + var rst = Result() try { if (site.type == 3) { val spider: Spider = ApiConfig.getSpider(site) @@ -91,7 +96,7 @@ object SiteViewModel { SpiderDebug.log("detail:$detailContent") ApiConfig.setRecent(site) rst = Jsons.decodeFromString(detailContent) - if (!rst.list.isEmpty()) rst.list.get(0).setVodFlags() + if (rst.list.isNotEmpty()) rst.list.get(0).setVodFlags() // if (!rst.list.isEmpty()) checkThunder(rst.list.get(0).vodFlags()) detail.value = rst } else if (site.key.isEmpty() && site.name.isEmpty() && key == "push_agent") { @@ -121,6 +126,7 @@ object SiteViewModel { return null } rst.list.forEach { it.site = site } + return rst } diff --git a/composeApp/src/commonMain/kotlin/com/corner/database/repository/ConfigRepository.kt b/composeApp/src/commonMain/kotlin/com/corner/database/repository/ConfigRepository.kt index ba422b1..6ae49b8 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/database/repository/ConfigRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/database/repository/ConfigRepository.kt @@ -24,6 +24,8 @@ interface ConfigRepository { fun updateSome( id:Int, json: String? = null, home: String? = null, parse: String? = null) fun updateUrl(id: Long, textFieldValue: String) fun setHome(url: String?, type: Int, key: String) + fun getAll(): List + abstract fun deleteById(id: Long?) } class ConfigRepositoryImpl : ConfigRepository, KoinComponent { @@ -72,4 +74,13 @@ class ConfigRepositoryImpl : ConfigRepository, KoinComponent { configQueries.setHome(key, type.toLong(), url) } + override fun getAll(): List { + return configQueries.getAll().executeAsList() + } + + override fun deleteById(id: Long?) { + id ?: return + configQueries.deleteById(id) + } + } diff --git a/composeApp/src/commonMain/kotlin/com/corner/database/repository/HistoryRepository.kt b/composeApp/src/commonMain/kotlin/com/corner/database/repository/HistoryRepository.kt index 0e040eb..4da22fc 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/database/repository/HistoryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/database/repository/HistoryRepository.kt @@ -25,16 +25,16 @@ interface HistoryRepository { fun deleteBatch(ids:List): Boolean fun deleteAll():Boolean + fun findHistory(historyKey: String): History? + fun updateOpeningEnding(opening: Long, ending: Long, key: String) + + fun updateSome(flag:String, vodRemarks:String, playUrl:String, position:Long, speed:Float,opening: Long, ending: Long, historyKey:String) } class HistoryRepositoryImpl:HistoryRepository, KoinComponent{ private val database: Database by inject() private val historyQueries = database.historyQueries - private fun getHistoryKey(key:String, id:String, cId:String): String { - return key + Db.SYMBOL + id + Db.SYMBOL + ApiConfig.api.id - } - /** * key = KEY@@@ID@@@CID */ @@ -57,7 +57,7 @@ class HistoryRepositoryImpl:HistoryRepository, KoinComponent{ } override fun create(vod:Vod, flag:String, vodRemarks: String){ - val historyKey = vod.site?.key + Db.SYMBOL + vod.vodId + Db.SYMBOL + ApiConfig.api.id + val historyKey = vod.site?.key + Db.SYMBOL + vod.vodId + Db.SYMBOL + ApiConfig.api.cfg.value?.id!! val his = historyQueries.findByKey(historyKey).executeAsOneOrNull() if(his == null){ save(historyKey, @@ -67,9 +67,9 @@ class HistoryRepositoryImpl:HistoryRepository, KoinComponent{ vodRemarks, vod.vodPlayUrl!!, ApiConfig.api.cfg.value?.id!!) - }else{ - historyQueries.updateSome(flag, vodRemarks, vod.vodPlayUrl, historyKey) - } + }/*else{ + historyQueries.updateSome(flag, vodRemarks, vod.vodPlayUrl, historyKey) + }*/ } override fun findAll(cId: Long?): List { @@ -95,6 +95,18 @@ class HistoryRepositoryImpl:HistoryRepository, KoinComponent{ return true } + override fun findHistory(historyKey: String): History? { + return historyQueries.findByKey(historyKey).executeAsOneOrNull() + } + + override fun updateOpeningEnding(opening: Long, ending: Long, key: String) { + historyQueries.updateOpeningEnding(opening = opening, ending = ending, key) + } + + override fun updateSome(flag: String, vodRemarks: String, playUrl: String, position: Long,speed:Float, opening: Long, ending: Long, historyKey: String) { + historyQueries.updateSome(flag, vodRemarks, playUrl, position,speed.toDouble(),opening, ending, historyKey) + } + } @@ -109,6 +121,7 @@ fun History.buildVod():Vod{ vod.vodId = keySplit[1] vod.vodPic = vodPic vod.vodRemarks = vodRemarks + vod.site = ApiConfig.getSite(keySplit[0]) return vod } diff --git a/composeApp/src/commonMain/kotlin/com/corner/database/repository/KeepRepository.kt b/composeApp/src/commonMain/kotlin/com/corner/database/repository/KeepRepository.kt new file mode 100644 index 0000000..8090696 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/database/repository/KeepRepository.kt @@ -0,0 +1,17 @@ +package com.corner.database.repository + +import com.corner.catvodcore.config.ApiConfig +import com.corner.database.Database +import com.corner.database.Keep +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class KeepRepository:KoinComponent{ + private val database: Database by inject() + private val keepQueries = database.keepQueries + + fun getAll():List{ + ApiConfig.api.cfg.value?.id ?: return listOf() + return keepQueries.getAll(ApiConfig.api.cfg.value?.id!!.toLong()).executeAsList() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/init/Init.kt b/composeApp/src/commonMain/kotlin/com/corner/init/Init.kt index 1e6238e..53c9d15 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/init/Init.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/init/Init.kt @@ -2,6 +2,8 @@ package com.corner.init import com.arkivanov.decompose.value.update import com.corner.bean.Hot +import com.corner.bean.SettingStore +import com.corner.bean.SettingType import com.corner.catvodcore.config.ApiConfig import com.corner.catvodcore.config.init import com.corner.catvodcore.enum.ConfigType @@ -10,8 +12,10 @@ import com.corner.catvodcore.viewmodel.GlobalModel import com.corner.database.Db import com.corner.database.appModule import com.corner.server.KtorD +import com.corner.ui.player.vlcj.VlcJInit import com.corner.ui.scene.hideProgress import com.corner.ui.scene.showProgress +import org.apache.commons.lang3.StringUtils import org.koin.core.context.startKoin import org.slf4j.LoggerFactory @@ -28,6 +32,7 @@ class Init { initConfig() initPlatformSpecify() Hot.getHotList() + VlcJInit.init() } finally { hideProgress() } @@ -55,6 +60,9 @@ fun initConfig() { ApiConfig.clear() GlobalModel.clear.update {!it} + val vod = SettingStore.getSettingItem(SettingType.VOD.id) + if(StringUtils.isBlank(vod)) return + // todo 清空点播设置后 仍然可以在数据库中查询到旧的配置 val siteConfig = Db.Config.findOneByType(ConfigType.SITE.ordinal.toLong()) ?: return try { ApiConfig.parseConfig(siteConfig, false).init() diff --git a/composeApp/src/commonMain/kotlin/com/corner/server/KtorD.kt b/composeApp/src/commonMain/kotlin/com/corner/server/KtorD.kt index 946df7f..1558ddf 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/server/KtorD.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/server/KtorD.kt @@ -4,9 +4,7 @@ import com.corner.server.plugins.configureRouting import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* -import io.ktor.server.plugins.callloging.* import org.slf4j.LoggerFactory -import org.slf4j.event.Level private val log = LoggerFactory.getLogger("KtorD") object KtorD { @@ -50,9 +48,9 @@ object KtorD { } private fun Application.module() { - install(CallLogging){ - level = Level.DEBUG - } +// install(CallLogging){ +// level = Level.DEBUG +// } // install(ContentNegotiation){ // json(Json { // isLenient = true diff --git a/composeApp/src/commonMain/kotlin/com/corner/server/plugins/Routings.kt b/composeApp/src/commonMain/kotlin/com/corner/server/plugins/Routings.kt index c82fceb..29ec2ce 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/server/plugins/Routings.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/server/plugins/Routings.kt @@ -50,11 +50,10 @@ fun Application.configureRouting() { if (objects.isEmpty()) errorResp(call) else if (objects[0] is Response) { response = objects[0] as Response - response.headers.forEach { i -> call.response.headers.append(i.first, i.second) } + response.headers.forEach { i -> if(!HttpHeaders.isUnsafe(i.first)) call.response.headers.append(i.first, i.second) } log.debug("proxy resp code:{} headers:{}", response.code, response.headers) call.respondOutputStream( status = HttpStatusCode.fromValue(response.code), -// status = HttpStatusCode.PartialContent, ) { response.body.byteStream().transferTo(this) } diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/Detail.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/Detail.kt index 9d417c0..f5d2ac1 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/Detail.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/Detail.kt @@ -1,10 +1,10 @@ package com.corner.ui +import AppTheme import SiteViewModel +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -21,31 +22,35 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import com.arkivanov.decompose.value.update +import com.corner.bean.SettingStore +import com.corner.catvod.enum.bean.Vod import com.corner.catvod.enum.bean.Vod.Companion.getPage -import com.corner.database.Db +import com.corner.catvodcore.bean.v +import com.corner.catvodcore.viewmodel.GlobalModel import com.corner.ui.decompose.DetailComponent +import com.corner.ui.player.vlcj.VlcjFrameController import com.corner.ui.scene.* import com.corner.ui.video.QuickSearchItem +import com.corner.util.Constants import com.corner.util.play.Play -import com.seiko.imageloader.ui.AutoSizeImage +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.apache.commons.lang3.StringUtils -@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun DetailScene(component: DetailComponent, onClickBack: () -> Unit) { val model = component.model.subscribeAsState() @@ -53,14 +58,17 @@ fun DetailScene(component: DetailComponent, onClickBack: () -> Unit) { val detail by rememberUpdatedState(model.value.detail) + val controller = rememberUpdatedState(component.controller) + + val isFullScreen = GlobalModel.videoFullScreen.subscribeAsState() + + val videoHeight = derivedStateOf { if (isFullScreen.value) 1f else 0.6f } + val videoWidth = derivedStateOf { if (isFullScreen.value) 1f else 0.7f } + + LaunchedEffect("detail") { component.load() } - DisposableEffect(model.value.detail) { - println("detail修改") - onDispose { - } - } DisposableEffect(model.value.isLoading) { if (model.value.isLoading) { @@ -71,296 +79,478 @@ fun DetailScene(component: DetailComponent, onClickBack: () -> Unit) { onDispose { } } + val focus = remember { FocusRequester() } + + LaunchedEffect(isFullScreen.value) { + focus.requestFocus() + } Box( modifier = Modifier.fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { - Column(Modifier.padding(8.dp)) { - BackRow(Modifier, onClickBack = { - onClickBack() - }) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(horizontalArrangement = Arrangement.Start) { - Text( - detail?.vodName ?: "", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 50.dp) - ) - } - - Row(horizontalArrangement = Arrangement.End) { - IconButton( - onClick = { - scope.launch { - component.clear() - component.quickSearch() - SnackBar.postMsg("重新加载") - } - }, - enabled = !model.value.isLoading - ) { - Icon( - Icons.Default.Autorenew, - contentDescription = "renew", - tint = MaterialTheme.colorScheme.onSecondaryContainer + Column(Modifier) { + if (!isFullScreen.value) { + BackRow(Modifier, onClickBack = { + onClickBack() + }) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.Start) { + Text( + detail?.vodName ?: "", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 50.dp) ) } - } - } - } - val searchResultList = derivedStateOf { model.value.quickSearchResult.toList() } - Row(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.weight(0.3f)) { - AutoSizeImage( - modifier = Modifier.clip(RoundedCornerShape(8.dp)) - .fillMaxHeight(0.4f), -// .height(180.dp).width(160.dp), - url = detail?.vodPic ?: "", - contentDescription = detail?.vodName, - contentScale = ContentScale.FillHeight, - placeholderPainter = { painterResource("/icon/empty.png") }, - errorPainter = { painterResource("/icon/empty.png") } - ) - if (model.value.quickSearchResult.isNotEmpty()) { - Spacer(Modifier.size(20.dp)) - val quickState = rememberLazyGridState() - val adapter = rememberScrollbarAdapter(quickState) - Box { - LazyVerticalGrid( - modifier = Modifier.padding(end = 10.dp), - columns = GridCells.Fixed(2), - state = quickState, - verticalArrangement = Arrangement.spacedBy(5.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp) - ) { - items(searchResultList.value) { - QuickSearchItem(it) { - SiteViewModel.viewModelScope.launch { - component.loadDetail(it) - } + + Row(horizontalArrangement = Arrangement.End) { + IconButton( + onClick = { + scope.launch { + component.clear() + component.quickSearch() + SnackBar.postMsg("重新加载") } - } - } - VerticalScrollbar( - modifier = Modifier.align(Alignment.CenterEnd), - adapter = adapter, style = defaultScrollbarStyle().copy( - unhoverColor = Color.Gray.copy(0.45F), - hoverColor = Color.DarkGray + }, + enabled = !model.value.isLoading + ) { + Icon( + Icons.Default.Autorenew, + contentDescription = "renew", + tint = MaterialTheme.colorScheme.onSecondaryContainer ) - ) + } } } } - val rememberScrollState = rememberScrollState(0) - if (model.value.detail == null) { - emptyShow() + } + val mrl = derivedStateOf { model.value.currentPlayUrl } + Row( + modifier = Modifier.fillMaxHeight(videoHeight.value), + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + val internalPlayer = derivedStateOf { + SettingStore.getPlayerSetting()[0] as Boolean + } + if (internalPlayer.value) { + SideEffect { + focus.requestFocus() + } + Player( + mrl.value, controller.value, Modifier.fillMaxWidth(videoWidth.value).focusable(), + component, + focusRequester = focus + ) } else { - Column( - modifier = Modifier.padding(start = 10.dp) - .scrollable(state = rememberScrollState, orientation = Orientation.Vertical) - .weight(0.8f) + Box( + Modifier + .fillMaxWidth(videoWidth.value) + .fillMaxHeight() + .background(Color.Black) ) { - Row { - if (detail?.site?.name?.isNotBlank() == true) { - Text("站源: " + detail?.site?.name, color = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.width(5.dp)) - } - val s = mutableListOf() - Text(detail?.vodYear ?: "", color = MaterialTheme.colorScheme.onSurface) - if (StringUtils.isNotBlank(detail?.vodArea)) { - s.add(detail?.vodArea!!) - } - if (StringUtils.isNotBlank(detail?.cate)) { - s.add(detail?.cate!!) - } - if (StringUtils.isNotBlank(detail?.typeName)) { - s.add(detail?.typeName!!) - } - Text(s.joinToString(separator = " | "), color = MaterialTheme.colorScheme.onSurface) - } - Text("导演:${detail?.vodDirector ?: "无"}", color = MaterialTheme.colorScheme.onSurface) - ExpandedText( - "演员:${detail?.vodActor ?: "无"}", - 2, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface) - ) - ExpandedText( - "简介:${detail?.vodContent?.trim() ?: "无"}", - 3, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface) - ) - // 线路 - Spacer(modifier = Modifier.size(20.dp)) Text( - "线路", - fontSize = TextUnit(25F, TextUnitType.Sp), - modifier = Modifier.padding(bottom = 5.dp), - color = MaterialTheme.colorScheme.onSurface + "使用外部播放器", + modifier = Modifier.align(Alignment.Center).focusRequester(focus), + fontWeight = FontWeight.Bold, + fontSize = TextUnit(23f, TextUnitType.Sp), + color = MaterialTheme.colorScheme.onBackground ) - if (detail?.vodFlags?.isNotEmpty() == true) { - val state = rememberLazyListState(0) - Box() { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(5.dp), - state = state, - modifier = Modifier.padding(bottom = 10.dp) - .fillMaxWidth() - .onPointerEvent(PointerEventType.Scroll) { - scope.launch { -// if(it.changes.size == 0) return@launch - state.scrollBy(it.changes.first().scrollDelta.y * state.layoutInfo.visibleItemsInfo.first().size) - } - }, - ) { - items(detail?.vodFlags?.toList() ?: listOf()) { - RatioBtn(it?.show ?: "", onClick = { - - for (vodFlag in detail?.vodFlags ?: listOf()) { - if (it?.show == vodFlag?.show) { - it?.activated = true - } else { - vodFlag?.activated = false - } - } - val dt = detail?.copy( - currentFlag = it, - subEpisode = it?.episodes?.getPage(detail!!.currentTabIndex) - ?.toMutableList() - ) - component.model.update { it.copy(detail = dt) } - }, selected = it?.activated ?: false) - } - } - if (state.layoutInfo.visibleItemsInfo.size < (detail?.vodFlags?.size ?: 0)) { - HorizontalScrollbar( - rememberScrollbarAdapter(state), - style = defaultScrollbarStyle().copy( - unhoverColor = Color.Gray.copy(0.45F), - hoverColor = Color.DarkGray - ), modifier = Modifier.align(Alignment.BottomCenter) - ) - } + } + } + AnimatedVisibility(!isFullScreen.value, modifier = Modifier.fillMaxSize()) { + EpChooser( + component, Modifier.fillMaxSize() + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.4f), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 5.dp) + ) + } + } + AnimatedVisibility(!isFullScreen.value) { + val searchResultList = derivedStateOf { model.value.quickSearchResult.toList() } + Box(Modifier) { + Row(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.size(15.dp)) + Column(Modifier.fillMaxWidth(0.3f)) { + quickSearchResult(model, searchResultList, component) + } + Column( + modifier = Modifier.padding(start = 10.dp) + .fillMaxSize() + ) { + if (model.value.detail == null) { + emptyShow(onRefresh = { component.load() }) + } else { + vodInfo(detail) } - // - Spacer(modifier = Modifier.size(20.dp)) - Row { - if (detail?.currentFlag != null && (detail?.currentFlag?.episodes?.size ?: 0) > 0) { + Spacer(modifier = Modifier.size(15.dp)) + // 线路 + flags(scope, controller.value, component) + Spacer(Modifier.size(15.dp)) + val urls = rememberUpdatedState(component.model.value.currentUrl) + val showUrl = derivedStateOf { (urls.value?.values?.size ?: 0) > 1 } + if (showUrl.value) { + Row { Text( - "选集", + "清晰度", fontSize = TextUnit(25F, TextUnitType.Sp), - modifier = Modifier.padding(bottom = 5.dp), - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.size(10.dp)) - Text( - "共${detail?.currentFlag?.episodes?.size}集", - textAlign = TextAlign.End, + modifier = Modifier.padding(bottom = 5.dp, end = 5.dp), color = MaterialTheme.colorScheme.onSurface ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + itemsIndexed(urls.value?.values ?: listOf()) { i, item -> + RatioBtn(item.n ?: (i + 1).toString(), onClick = { + component.model.update { + it.copy( + currentUrl = urls.value?.copy(position = i), + currentPlayUrl = item.v ?: "" + ) + } + }, i == urls.value?.position!!) + } + } + } } + } + } + } + } + } + val showEpChooserDialog = derivedStateOf { isFullScreen.value && model.value.showEpChooserDialog } + Dialog(Modifier.align(Alignment.CenterEnd) + .fillMaxWidth(0.3f) + .fillMaxHeight(0.8f) + .padding(end = 20.dp), + showDialog = showEpChooserDialog.value, + onClose = { component.model.update { it.copy(showEpChooserDialog = false) } }) { + EpChooser( + component, Modifier.fillMaxSize() + .background( + MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 5.dp) + ) + } - val epSize = detail?.currentFlag?.episodes?.size ?: 0 + } - val scrollState = rememberLazyListState(0) - val scrollBarAdapter = rememberScrollbarAdapter(scrollState) - if (epSize > 15) { - Box(modifier = Modifier.padding(bottom = 2.dp)) { - LazyRow( - state = scrollState, - modifier = Modifier.padding(bottom = 2.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp) - ) { - for (i in 0 until epSize step 15) { - item { - RatioBtn( - selected = detail?.currentTabIndex == (i / 15), - onClick = { - detail?.currentTabIndex = i / 15 - val dt = detail?.copy( - subEpisode = detail?.currentFlag?.episodes?.getPage(detail!!.currentTabIndex) - ?.toMutableList() - ) - component.model.update { it.copy(detail = dt) } - }, - text = "${i + 1}-${i + 15}" - ) - } - } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun flags( + scope: CoroutineScope, + controller: VlcjFrameController, + component: DetailComponent +) { + val model = component.model.subscribeAsState() + val detail = derivedStateOf { model.value.detail } + Row(Modifier.padding(start = 10.dp)) { + Text( + "线路", + fontSize = TextUnit(25F, TextUnitType.Sp), + modifier = Modifier.padding(bottom = 5.dp), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.size(10.dp)) + val detailIsNotEmpty = derivedStateOf { detail.value?.vodFlags?.isNotEmpty() } + if (detailIsNotEmpty.value == true) { + val state = rememberLazyListState(0) + Box() { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(5.dp), + state = state, + modifier = Modifier.padding(bottom = 10.dp) + .fillMaxWidth() + .onPointerEvent(PointerEventType.Scroll) { + scope.launch { +// if(it.changes.size == 0) return@launch + state.scrollBy(it.changes.first().scrollDelta.y * state.layoutInfo.visibleItemsInfo.first().size) + } + }, + ) { + val flagList = derivedStateOf { detail.value?.vodFlags?.toList() ?: listOf() } + items(flagList.value) { + RatioBtn(it?.show ?: "", onClick = { +// scope.launch { + for (vodFlag in detail.value!!.vodFlags) { + if (it?.show == vodFlag?.show) { + it?.activated = true + } else { + vodFlag?.activated = false } - HorizontalScrollbar( - adapter = scrollBarAdapter, - modifier = Modifier.padding(bottom = 5.dp).align(Alignment.BottomCenter), - style = defaultScrollbarStyle().copy( - unhoverColor = Color.Gray.copy(0.45F), - hoverColor = Color.DarkGray - ) + } + val dt = detail.value!!.copy( + currentFlag = it, + subEpisode = it?.episodes?.getPage(detail.value!!.currentTabIndex) + ?.toMutableList() + ) + val history = controller.history.value + if (history != null) { + val findEp = + detail.value!!.findAndSetEpByName(controller.history.value!!) + if (findEp != null) component.playEp(dt, findEp) + } + component.model.update { model -> + model.copy( + detail = dt, + shouldPlay = true, ) } - } - val videoLoading = remember { mutableStateOf(false) } - LazyVerticalGrid( - columns = GridCells.Fixed(2), - state = rememberLazyGridState(), - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - items( - detail?.subEpisode ?: listOf() - ) { - TooltipArea( - tooltip = { - // composable tooltip content - Surface( - modifier = Modifier.shadow(4.dp), -// color = MaterialTheme.colors.surface, - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = it.name ?: "", - modifier = Modifier.padding(10.dp), -// color = MaterialTheme.colors.onSurface - ) - } - }, - delayMillis = 600 - ) { - RatioBtn(text = it.name ?: "", onClick = { - videoLoading.value = true - SiteViewModel.viewModelScope.launch { - for (i in detail?.currentFlag?.episodes ?: listOf()) { - i.activated = (i.name == it.name) - } - val dt = detail?.copy( - subEpisode = detail?.currentFlag?.episodes?.getPage(detail!!.currentTabIndex) - ?.toMutableList()?.toList()?.toMutableList(), - version = (detail!!.version++) - ) - component.model.update { it.copy(detail = dt) } - val result = SiteViewModel.playerContent( - detail?.site?.key ?: "", - detail?.currentFlag?.flag ?: "", - it.url ?: "" - ) - Play.start(result, it.name ?: detail?.vodName) - Db.History.create(detail!!, detail?.currentFlag?.flag!!, it.name!!) - }.invokeOnCompletion { - videoLoading.value = false - } - }, selected = it.activated, it.activated && videoLoading.value) +// } + }, selected = it?.activated ?: false) + } + } + val showScrollBar = + derivedStateOf { state.layoutInfo.visibleItemsInfo.size < (detail.value!!.vodFlags.size) } + if (showScrollBar.value) { + HorizontalScrollbar( + rememberScrollbarAdapter(state), + style = defaultScrollbarStyle().copy( + unhoverColor = Color.Gray.copy(0.45F), + hoverColor = Color.DarkGray + ), modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } + } + } +} + +@Composable +private fun quickSearchResult( + model: State, + searchResultList: State>, + component: DetailComponent +) { + if (model.value.quickSearchResult.isNotEmpty()) { + val quickState = rememberLazyGridState() + val adapter = rememberScrollbarAdapter(quickState) + Box { + LazyVerticalGrid( + modifier = Modifier.padding(end = 10.dp), + columns = GridCells.Fixed(2), + state = quickState, + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + items(searchResultList.value) { + QuickSearchItem(it) { + SiteViewModel.viewModelScope.launch { + component.loadDetail(it) + } + } + } + } + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd), + adapter = adapter, style = defaultScrollbarStyle().copy( + unhoverColor = Color.Gray.copy(0.45F), + hoverColor = Color.DarkGray + ) + ) + } + } +} + +@Composable +private fun vodInfo(detail: Vod?) { + Column(Modifier.padding(10.dp)) { + Row() { + if (detail?.site?.name?.isNotBlank() == true) { + Text( + "站源: " + detail.site?.name, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.width(5.dp)) + } + val s = mutableListOf() + Text(detail?.vodYear ?: "", color = MaterialTheme.colorScheme.onSurface) + if (StringUtils.isNotBlank(detail?.vodArea)) { + s.add(detail?.vodArea!!) + } + if (StringUtils.isNotBlank(detail?.cate)) { + s.add(detail?.cate!!) + } + if (StringUtils.isNotBlank(detail?.typeName)) { + s.add(detail?.typeName!!) + } + Text( + s.joinToString(separator = " | "), + color = MaterialTheme.colorScheme.onSurface + ) + } + Text( + "导演:${detail?.vodDirector ?: "无"}", + color = MaterialTheme.colorScheme.onSurface + ) + ExpandedText( + "演员:${detail?.vodActor ?: "无"}", + 2, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface) + ) + ExpandedText( + "简介:${detail?.vodContent?.trim() ?: "无"}", + 3, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EpChooser(component: DetailComponent, modifier: Modifier) { + val model = component.model.subscribeAsState() + val detail = rememberUpdatedState(model.value.detail) + Column(modifier = modifier) { + Row(Modifier.padding(vertical = 3.dp, horizontal = 8.dp)) { + Text( + "选集", + fontSize = TextUnit(20F, TextUnitType.Sp), + modifier = Modifier.padding(bottom = 5.dp), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.size(10.dp)) + val show = derivedStateOf { + detail.value?.currentFlag != null && (detail.value?.currentFlag?.episodes?.size ?: 0) > 0 + } + if (show.value) { + Text( + "共${detail.value?.currentFlag?.episodes?.size}集", + textAlign = TextAlign.End, + fontSize = TextUnit(15F, TextUnitType.Sp), + color = MaterialTheme.colorScheme.onSurface + ) + } + } + val epSize = derivedStateOf { detail.value?.currentFlag?.episodes?.size ?: 0 } + + val scrollState = rememberLazyListState(0) + val scrollBarAdapter = rememberScrollbarAdapter(scrollState) + if (epSize.value > 15) { + Box(modifier = Modifier.padding(bottom = 2.dp)) { + LazyRow( + state = scrollState, + modifier = Modifier.padding(bottom = 2.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + for (i in 0 until epSize.value step Constants.EpSize) { + item { + RatioBtn( + selected = detail.value?.currentTabIndex == (i / Constants.EpSize), + onClick = { + detail.value?.currentTabIndex = i / Constants.EpSize + val dt = detail.value?.copy( + subEpisode = detail.value?.currentFlag?.episodes?.getPage( + detail.value!!.currentTabIndex + ) + ?.toMutableList() + ) + component.model.update { it.copy(detail = dt) } + }, + text = "${i + 1}-${i + Constants.EpSize}" + ) + } + } + } + HorizontalScrollbar( + adapter = scrollBarAdapter, + modifier = Modifier.padding(bottom = 5.dp) + .align(Alignment.BottomCenter), + style = defaultScrollbarStyle().copy( + unhoverColor = Color.Gray.copy(0.45F), + hoverColor = Color.DarkGray + ) + ) + } + } + val videoLoading = remember { mutableStateOf(false) } + val epList = derivedStateOf { detail.value?.subEpisode ?: listOf() } + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = rememberLazyGridState(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + items( + epList.value, key = { it.url + it.number } + ) { + TooltipArea( + tooltip = { + // composable tooltip content + Surface( + modifier = Modifier.shadow(4.dp), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = it.name, + modifier = Modifier.padding(10.dp), + ) + } + }, + delayMillis = 600 + ) { + RatioBtn(text = it.name, onClick = { + videoLoading.value = true + SiteViewModel.viewModelScope.launch { + for (i in detail.value?.currentFlag?.episodes ?: listOf()) { + i.activated = (i.name == it.name) + if (i.activated) { + component.model.update { model -> + if (model.currentEp?.name != it.name) { + component.controller.doWithHistory { it.copy(position = 0L) } + } + component.controller.doWithHistory { + it.copy( + episodeUrl = i.url, + vodRemarks = i.name + ) + } + model.copy(currentEp = i) } } } + val dt = detail.value?.copy( + subEpisode = detail.value?.currentFlag?.episodes?.getPage( + detail.value!!.currentTabIndex + ) + ?.toMutableList()?.toList()?.toMutableList(), + ) + component.model.update { it.copy(detail = dt) } + val result = SiteViewModel.playerContent( + detail.value?.site?.key ?: "", + detail.value?.currentFlag?.flag ?: "", + it.url + ) + component.model.update { it.copy(currentUrl = result?.url) } + val internalPlayer = SettingStore.getPlayerSetting()[0] as Boolean + if (internalPlayer) { + component.play(result) + } else { + Play.start(result?.url?.v() ?: "", model.value.currentEp?.name) + } + }.invokeOnCompletion { + videoLoading.value = false } - } + }, selected = it.activated, it.activated && videoLoading.value) } } } } +} -} \ No newline at end of file +@androidx.compose.desktop.ui.tooling.preview.Preview +@Composable +fun previewEmptyShow() { + AppTheme { + emptyShow(onRefresh = { println("ddd") }) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/History.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/History.kt index cd0dc60..4aff79f 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/History.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/History.kt @@ -60,8 +60,8 @@ fun HistoryItem( modifier = Modifier.height(220.dp), contentDescription = history.vodName, contentScale = ContentScale.Crop, - placeholderPainter = { painterResource("/icon/empty.png") }, - errorPainter = { painterResource("/icon/empty.png") }) + placeholderPainter = { painterResource("/pic/empty.png") }, + errorPainter = { painterResource("/pic/empty.png") }) Text( text = history.vodName!!, modifier = Modifier.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)).align(Alignment.BottomCenter) diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/Player.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/Player.kt new file mode 100644 index 0000000..a4b2f87 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/Player.kt @@ -0,0 +1,234 @@ +package com.corner.ui + +import androidx.compose.animation.* +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState +import com.corner.bean.PlayerStateCache +import com.corner.bean.SettingStore +import com.corner.catvodcore.viewmodel.GlobalModel +import com.corner.ui.decompose.DetailComponent +import com.corner.ui.player.DefaultControls +import com.corner.ui.player.PlayerState +import com.corner.ui.player.frame.FrameContainer +import com.corner.ui.player.vlcj.VlcjFrameController +import com.corner.ui.scene.Dialog +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.apache.commons.lang3.StringUtils +import org.slf4j.LoggerFactory +import java.awt.Cursor +import java.awt.Point +import java.awt.Robot +import java.awt.Toolkit +import java.awt.image.BufferedImage +import java.util.* +import kotlin.concurrent.timerTask +import kotlin.math.abs + +const val VIDEO_URL = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" + +private val log = LoggerFactory.getLogger("Player") + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +fun Player( + mrl: String, + controller: VlcjFrameController, + modifier: Modifier, + component: DetailComponent, + focusRequester: FocusRequester +) { + val scope = rememberCoroutineScope() + val showControllerBar = remember(mrl) { mutableStateOf(true) } + val controlBarDuration = 5000L + val hideJob = remember { mutableStateOf(null) } + val cursorJob = remember { mutableStateOf(null) } + var keepScreenOnJob: Timer? = remember { null } + var mousePosition by remember { mutableStateOf(Offset.Zero) } + val showTip = controller.showTip.collectAsState() + val tip = controller.tip.collectAsState() + val videoFullScreen = GlobalModel.videoFullScreen.subscribeAsState() + val showMediaInfoDialog = remember { mutableStateOf(false) } + + LaunchedEffect(Unit){ + val volume = SettingStore.getCache("playerState") + if(volume != null){ + val v = (volume as PlayerStateCache).get("volume")?.toFloat() + controller.doWithPlayState { it.update { it.copy(volume = v ?: .8f) }} + } + } + + DisposableEffect(videoFullScreen.value, showControllerBar.value){ + try { + keepScreenOnJob?.cancel() + if(videoFullScreen.value && !showControllerBar.value){ + var time = 1 + if (videoFullScreen.value) { + keepScreenOnJob = Timer("keepScreenOn") + keepScreenOnJob?.scheduleAtFixedRate(timerTask { + val robot = Robot() + val v = if (time % 2 == 0) 1 else -1 + robot.mouseMove((mousePosition.x + v).toInt(), mousePosition.y.toInt()) + time++ + }, 0, 6000L) + } else { + keepScreenOnJob?.cancel() + } + } + } catch (e: Exception) { + log.error("keep screen on timer err:",e) + } + onDispose { + keepScreenOnJob?.cancel() + } + } + + val showCursor = remember { mutableStateOf(true) } + DisposableEffect(mrl) { + scope.launch { + if(StringUtils.isNotBlank(mrl)){ + controller.load(mrl) + } + } + onDispose { + } + } + Box(modifier.onPointerEvent(PointerEventType.Move) { + val current = it.changes.first().position + val cel = mousePosition.minus(current) + if (abs(cel.x) < 2 || abs(cel.y) < 2) return@onPointerEvent + + showControllerBar.value = true + mousePosition = current + hideJob.value?.cancel() + hideJob.value = scope.launch { + delay(controlBarDuration) + showControllerBar.value = false + } + cursorJob.value?.cancel() + showCursor.value = true + cursorJob.value = scope.launch { + delay(3000) + showCursor.value = false + } + }.pointerHoverIcon(PointerIcon(if (!showCursor.value) createEmptyCursor() else Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)))) { + FrameContainer(Modifier.fillMaxSize().focusTarget().focusable().focusRequester(focusRequester), controller){ + showControllerBar.value = !showControllerBar.value + } + AnimatedVisibility(showControllerBar.value, + modifier = Modifier.align(Alignment.TopEnd), + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()){ + Row (Modifier.height(40.dp).fillMaxWidth(), horizontalArrangement = Arrangement.End){ + IconButton(onClick = { + showMediaInfoDialog.value = true + }){ + Icon(Icons.Default.Info, contentDescription = "media info", tint = MaterialTheme.colorScheme.primary) + } + } + } + AnimatedVisibility( + showControllerBar.value, + modifier = Modifier.align(Alignment.BottomEnd), + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + DefaultControls( + Modifier.background(Color.Gray.copy(alpha = 0.45f)) + .height(80.dp) + .align(Alignment.BottomEnd), controller, component + ) + } + LaunchedEffect(tip.value){ + delay(1500) + controller.tip.emit("") + controller.showTip.emit(false) + } + AnimatedVisibility(showTip.value) { + Surface( + Modifier.padding(start = 10.dp, top = 10.dp), + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.5f) + ) { + Text( + tip.value, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(10.dp), + fontSize = TextUnit(24f, TextUnitType.Sp) + ) + } + } + MediaInfoDialog(Modifier.fillMaxWidth(0.5f).fillMaxHeight(0.4f), controller.state.value, showMediaInfoDialog.value){ + showMediaInfoDialog.value = false + } + } +} + +@Composable +fun MediaInfoDialog(modifier: Modifier, playerState: PlayerState, show:Boolean, onClose:()->Unit){ + val mediaInfo = rememberUpdatedState(playerState.mediaInfo) + Dialog(modifier, showDialog = show, onClose = onClose){ + val scrollbar = rememberLazyListState(0) + Box{ + LazyColumn(verticalArrangement = Arrangement.spacedBy(30.dp), modifier = Modifier.padding(30.dp), state = scrollbar, + horizontalAlignment = Alignment.CenterHorizontally) { + item { + SelectionContainer { + Text(text = AnnotatedString(mediaInfo.value?.url ?: "")) + } + Spacer(Modifier.size(40.dp)) + Text("${mediaInfo.value?.width ?: ""} * ${mediaInfo.value?.height ?: ""}") + } + } + VerticalScrollbar(rememberScrollbarAdapter(scrollbar), + modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 5.dp, horizontal = 8.dp), + style = defaultScrollbarStyle().copy( + unhoverColor = Color.Gray.copy(0.45F), + hoverColor = Color.DarkGray + )) + } + } +} + +//@Preview +//@Composable +//fun previewMediaInfoDialog(){ +// AppTheme { +// MediaInfoDialog(Modifier.fillMaxSize(), MediaInfo(800, 1200, "http://xxxxxx.com/dddd"), true){ +// +// } +// } +//} + +private fun createEmptyCursor(): Cursor { + return Toolkit.getDefaultToolkit().createCustomCursor( + BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB), + Point(1, 1), + "Empty Cursor" + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/Settings.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/Settings.kt index 314dbfd..94ea526 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/Settings.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/Settings.kt @@ -10,75 +10,55 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Code import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState -import com.corner.bean.Setting import com.corner.bean.SettingStore import com.corner.bean.SettingType import com.corner.catvodcore.config.ApiConfig import com.corner.catvodcore.enum.ConfigType +import com.corner.catvodcore.util.Paths +import com.corner.database.Config import com.corner.database.Db import com.corner.init.initConfig import com.corner.ui.decompose.component.DefaultSettingComponent +import com.corner.ui.decompose.component.getSetting import com.corner.ui.scene.BackRow import com.corner.ui.scene.Dialog import com.corner.ui.scene.HoverableText import com.corner.ui.scene.SnackBar import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jsoup.internal.StringUtil import java.awt.Desktop import java.net.URI -@Composable -fun SettingItem(modifier: Modifier, label: String, value: String?, onClick: () -> Unit) { - Row( - modifier - .clickable { - onClick() - }.shadow(3.dp) - .background(MaterialTheme.colorScheme.background, shape = RoundedCornerShape(4.dp)) - .padding(start = 20.dp, end = 20.dp) - ) { - Text( - label, - modifier = Modifier.padding(vertical = 8.dp, horizontal = 15.dp), - color = MaterialTheme.colorScheme.onBackground - ) - Text( - text = if (StringUtil.isBlank(value)) "无" else value ?: "", - modifier = Modifier.padding(vertical = 8.dp, horizontal = 15.dp) - .weight(0.5f), - color = MaterialTheme.colorScheme.onBackground - ) - } -} - @Composable fun SettingScene(component: DefaultSettingComponent, onClickBack: () -> Unit) { val model = component.model.subscribeAsState() - var showEditDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) } - var currentChoose by remember { mutableStateOf(null) } DisposableEffect("setting") { component.sync() onDispose { @@ -96,23 +76,155 @@ fun SettingScene(component: DefaultSettingComponent, onClickBack: () -> Unit) { .background(MaterialTheme.colorScheme.background) ) { Column(Modifier.fillMaxSize()) { - BackRow(Modifier, onClickBack = { onClickBack() }) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically){ + BackRow(Modifier.align(Alignment.Start), onClickBack = { onClickBack() }) { + Row( + Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( "设置", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.CenterVertically) ) + OutlinedButton( + onClick = { Desktop.getDesktop().open(Paths.userDataRoot()) }, + modifier = Modifier.align(Alignment.CenterVertically) + ) { + Text("打开用户数据目录") + } } -// IconButton(modifier = Modifier.align(Alignment.End), onClick = {showAboutDialog = true}){ Icon(Icons.Default.Info, "About", tint = MaterialTheme.colorScheme.onSecondary) } } - LazyColumn { - items(model.value.settingList) { - SettingItem( - Modifier, it.label, it.value - ) { - showEditDialog = true - currentChoose = it + LazyColumn(contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { + item { + val focusRequester = remember { FocusRequester() } + val isExpand = remember { mutableStateOf(false) } + val setting = remember { model.value.settingList.getSetting(SettingType.VOD) } + val vodConfigList = remember { mutableStateListOf(null) } + LaunchedEffect(isExpand.value) { + if (isExpand.value) { + val list: List = Db.Config.getAll() + vodConfigList.clear() + vodConfigList.addAll(list) + focusRequester.requestFocus() + } + } + SettingItemTemplate(setting?.label!!) { + Box(Modifier.fillMaxSize()) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + TextField( + value = setting.value ?: "", + onValueChange = { + SettingStore.setValue(SettingType.VOD, it) + component.sync() + focusRequester.requestFocus() + }, + maxLines = 1, + enabled = true, + modifier = Modifier.focusRequester(focusRequester) + .fillMaxHeight(0.6f) + .weight(0.9f) + .align(Alignment.CenterVertically) + .clip(RoundedCornerShape(5.dp)) + .onFocusEvent { + isExpand.value = it.isFocused + } + ) + Button( + onClick = { + setConfig(setting.value) + }, + modifier = Modifier.weight(0.1f) + ) { + Text("确定") + } + } + DropdownMenu( + isExpand.value, + { isExpand.value = false }, + modifier = Modifier.fillMaxWidth(0.8f), + properties = PopupProperties(focusable = false) + ) { + vodConfigList.forEach { + DropdownMenuItem(modifier = Modifier.fillMaxWidth(), + text = { Text(it?.url ?: "") }, + onClick = { + setConfig(it?.url) + isExpand.value = false + }, trailingIcon = { + IconButton(onClick = { + SiteViewModel.viewModelScope.launch { + Db.Config.deleteById(it?.id) + } + vodConfigList.remove(it) + }) { + Icon(Icons.Default.Close, "delete the config") + } + }) + } + } + } + } + } + item { + SettingItemTemplate("日志") { + LogButtonList(Modifier) { + SettingStore.setValue(SettingType.LOG, it) + component.sync() + SnackBar.postMsg("重启生效") + } + } + } + item { + SettingItemTemplate("播放器") { + val playerSetting = derivedStateOf { + SettingStore.getPlayerSetting() + } + Box { + Row { + Switch(playerSetting.value[0] as Boolean, onCheckedChange = { + SettingStore.setValue(SettingType.PLAYER, "$it#${playerSetting.value[1]}") + if (it) SnackBar.postMsg("使用内置播放器") else SnackBar.postMsg("使用外部播放器 请配置播放器路径") + component.sync() + }, Modifier.width(100.dp).padding(end = 20.dp).align(Alignment.CenterVertically), + thumbContent = { + Box(Modifier.size(80.dp)) { + Text( + if (playerSetting.value[0] as Boolean) "内置" else "外置", + Modifier.fillMaxSize().align(Alignment.Center) + ) + } + }) + // 只有外部播放器时展示 + if (!(playerSetting.value[0] as Boolean)) { + TextField( + value = playerSetting.value[1] as String, + onValueChange = { + SettingStore.setValue(SettingType.PLAYER, "${playerSetting.value[0]}#$it") + component.sync() + }, + maxLines = 1, + enabled = true, + modifier = Modifier.fillMaxHeight(0.8f).fillMaxWidth().align(Alignment.CenterVertically) + ) + } + } + } + } + } + item { + Box(Modifier.fillMaxSize().padding(top = 10.dp)) { + ElevatedButton( + onClick = { + SettingStore.reset() + component.sync() + SnackBar.postMsg("重置设置 重启生效") + }, Modifier.fillMaxWidth(0.8f) + .align(Alignment.Center) + ) { + Text("重置") + } } } } @@ -123,91 +235,161 @@ fun SettingScene(component: DefaultSettingComponent, onClickBack: () -> Unit) { } } AboutDialog(Modifier.fillMaxSize(0.4f), showAboutDialog) { showAboutDialog = false } - DialogEdit(showEditDialog, onClose = { showEditDialog = false }, currentChoose = currentChoose) { - component.sync() - } } } +fun SettingStore.getPlayerSetting(): List { + val settingItem = getSettingItem(SettingType.PLAYER.id) + val split = settingItem.split("#") + return if (split.size == 1) listOf(false, settingItem) else listOf(split[0].toBoolean(), split[1]) +} + @Composable -fun DialogEdit( - showEditDialog: Boolean, - onClose: () -> Unit, - currentChoose: Setting?, - onValueChange: (v: String) -> Unit -) { - var textFieldValue by remember { mutableStateOf(currentChoose?.value) } - val focusRequester = remember { FocusRequester() } - if (showEditDialog) { - LaunchedEffect(Unit) { - textFieldValue = currentChoose?.value - focusRequester.requestFocus() - } +fun SettingItemTemplate(title: String, content: @Composable () -> Unit) { + Row( + Modifier +// .background(MaterialTheme.colorScheme.background, shape = RoundedCornerShape(4.dp)) + .padding(start = 20.dp, end = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + title, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 15.dp).align(Alignment.CenterVertically), + color = MaterialTheme.colorScheme.onBackground + ) + content() } - Dialog( - modifier = Modifier.fillMaxWidth(0.5f), - showEditDialog, - onClose = { - onClose() - } +} + +private val logLevel = listOf("INFO", "DEBUG") + +@Composable +fun LogButtonList(modifier: Modifier, onClick: (String) -> Unit) { + val current = derivedStateOf { SettingStore.getSettingItem(SettingType.LOG.id) } + Box( + Modifier.padding(horizontal = 10.dp), + contentAlignment = Alignment.Center ) { - Column(modifier = Modifier.padding(horizontal = 15.dp, vertical = 25.dp)) { - Row(modifier = Modifier.fillMaxWidth()) { - Text(currentChoose?.label ?: ""/*, color = MaterialTheme.colors.onBackground*/) - Spacer(modifier = Modifier.size(35.dp)) - TextField( - modifier = Modifier.fillMaxWidth(0.8f).focusRequester(focusRequester), - enabled = true, - value = textFieldValue ?: "", - onValueChange = { text -> textFieldValue = text }, - interactionSource = MutableInteractionSource(), - maxLines = 2 - ) + Row { + logLevel.forEachIndexed { i, t -> + if (i == 0) { + SideButton(current.value == t, text = t, type = SideButtonType.LEFT) { + onClick(it) + } + } else if (i == logLevel.size - 1) { + SideButton(current.value == t, text = t, type = SideButtonType.RIGHT) { + onClick(it) + + } + } else { + SideButton(current.value == t, text = t) { + onClick(it) + } + } } - Button(modifier = Modifier.align(Alignment.End), onClick = { - SiteViewModel.viewModelScope.launch { - when (currentChoose!!.id) { - "vod" -> { - if (textFieldValue == null || textFieldValue == "") { - SnackBar.postMsg("不可为空") - return@launch - } - val config = Db.Config.find(textFieldValue!!, ConfigType.SITE.ordinal.toLong()) - if (config == null) { - Db.Config.save( - type = ConfigType.SITE.ordinal.toLong(), - url = textFieldValue - ) - } else { - Db.Config.updateUrl(config.id, textFieldValue as String) - } - SettingStore.setValue(SettingType.VOD, textFieldValue!!) - ApiConfig.api.cfg.value = Db.Config.find(textFieldValue!!, ConfigType.SITE.ordinal.toLong()) - initConfig() - } + } + } +} - "player" -> { - SettingStore.setValue(SettingType.PLAYER, textFieldValue!!) - } +enum class SideButtonType { + LEFT, MID, RIGHT +} - "log" -> { - if (textFieldValue == null || textFieldValue == "") { - SnackBar.postMsg("不可为空") - return@launch - } - SettingStore.setValue(SettingType.LOG, textFieldValue!!) - SnackBar.postMsg("重启生效") - } - } - }.invokeOnCompletion { - onValueChange(textFieldValue ?: "") - onClose() +@Composable +fun SideButton( + choosed: Boolean, + buttonColors: ButtonColors = ButtonDefaults.buttonColors() + .copy(disabledContainerColor = MaterialTheme.colorScheme.background), + type: SideButtonType = SideButtonType.MID, + text: String, + onClick: (String) -> Unit +) { + val textColor = if (choosed) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onBackground + Text(text = text, modifier = Modifier.clickable { onClick(text) } + .defaultMinSize(50.dp) + .drawWithCache { +// val width = size.width * 1.1f +// val height = size.height * 1.1f + val width = size.width + val height = size.height + val color = if (choosed) buttonColors.containerColor else buttonColors.disabledContainerColor + + onDrawBehind { + val rectOffset = when (type) { + SideButtonType.LEFT -> Offset(height / 2, 0f) + SideButtonType.MID -> Offset.Zero + SideButtonType.RIGHT -> Offset.Zero + } + if (type == SideButtonType.LEFT) { + drawCircle( + color = color, + radius = height / 2, + center = Offset(height / 2, height / 2), + style = Fill + ) + } + val rectSize = Size(width - height / 2, height) + drawRect( + color = color, + topLeft = rectOffset, + size = rectSize, + style = Fill, + ) + if (type == SideButtonType.RIGHT) { + drawCircle( + color = color, + radius = height / 2, + center = Offset(size.width - height / 2, height / 2), + style = Fill + ) } - }) { - Text("确认") } + }, textAlign = TextAlign.Center, color = textColor) +} + +@Preview +@Composable +fun previewSideButton() { + AppTheme { + Row(Modifier.fillMaxSize()) { + LogButtonList(Modifier) {} +// SideButton(true, text = "test12312", type = SideButtonType.LEFT) {} +// +// SideButton(false, text = "test1j计划熊㩐动甮的", type = SideButtonType.RIGHT) {} + } +// Column(Modifier.fillMaxSize()) { +// SideButton(Color.Blue, false, "test1") {} +// } + } +} + +@Preview +@Composable +fun previewLogButtonList() { + AppTheme { + LogButtonList(Modifier) {} + } +} + +fun setConfig(textFieldValue: String?) { + SiteViewModel.viewModelScope.launch { + if (textFieldValue == null || textFieldValue == "") { + SnackBar.postMsg("不可为空") + return@launch + } + val config = Db.Config.find(textFieldValue, ConfigType.SITE.ordinal.toLong()) + if (config == null) { + Db.Config.save( + type = ConfigType.SITE.ordinal.toLong(), + url = textFieldValue + ) + } else { + Db.Config.updateUrl(config.id, textFieldValue) } + SettingStore.setValue(SettingType.VOD, textFieldValue) + ApiConfig.api.cfg.value = Db.Config.find(textFieldValue, ConfigType.SITE.ordinal.toLong()) + initConfig() } } @@ -232,7 +414,7 @@ fun AboutDialog(modifier: Modifier, showAboutDialog: Boolean, onClose: () -> Uni ) { Column { Image( - painter = painterResource("/icon/avatar.png"), + painter = painterResource("/pic/avatar.png"), contentDescription = "avatar", contentScale = ContentScale.Fit, modifier = Modifier.padding(8.dp) diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/VideoPlayer.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/VideoPlayer.kt deleted file mode 100644 index 4df9c5a..0000000 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/VideoPlayer.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.corner.ui - -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.listSaver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier - -data class Progress( - val fraction: Float, - // TODO: Use kotlin.time.Duration when Kotlin version is updated. - // See https://github.com/Kotlin/api-guidelines/issues/6 - val timeMillis: Long -) - -@Composable -fun VideoPlayer( - url: String, - state: VideoPlayerState, - modifier: Modifier = Modifier, - onFinish: (() -> Unit)? = null -) = VideoPlayerImpl( - url = url, - isResumed = state.isResumed, - volume = state.volume, - speed = state.speed, - seek = state.seek, - isFullscreen = state.isFullscreen, - progressState = state._progress, - modifier = modifier, - onFinish = onFinish -) - -@Composable -internal expect fun VideoPlayerImpl( - url: String, - isResumed: Boolean, - volume: Float, - speed: Float, - seek: Float, - isFullscreen: Boolean, - progressState: MutableState, - modifier: Modifier, - onFinish: (() -> Unit)? -) - -@Composable -fun rememberVideoPlayerState( - seek: Float = 0f, - speed: Float = 1f, - volume: Float = 1f, - isResumed: Boolean = true, - isFullscreen: Boolean = false -): VideoPlayerState = rememberSaveable(saver = VideoPlayerState.Saver()) { - VideoPlayerState( - seek, - speed, - volume, - isResumed, - isFullscreen, - Progress(0f, 0) - ) -} - -class VideoPlayerState( - seek: Float = 0f, - speed: Float = 1f, - volume: Float = 1f, - isResumed: Boolean = true, - isFullscreen: Boolean = false, - progress: Progress -) { - - var seek by mutableStateOf(seek) - var speed by mutableStateOf(speed) - var volume by mutableStateOf(volume) - var isResumed by mutableStateOf(isResumed) - var isFullscreen by mutableStateOf(isFullscreen) - internal val _progress = mutableStateOf(progress) - val progress: State = _progress - - fun toggleResume() { - isResumed = !isResumed - } - - fun toggleFullscreen() { - isFullscreen = !isFullscreen - } - - fun stopPlayback() { - isResumed = false - } - - companion object { - /** - * The default [Saver] implementation for [VideoPlayerState]. - */ - fun Saver() = listSaver( - save = { - listOf( - it.seek, - it.speed, - it.volume, - it.isResumed, - it.isFullscreen, - it.progress.value - ) - }, - restore = { - VideoPlayerState( - seek = it[0] as Float, - speed = it[1] as Float, - volume = it[2] as Float, - isResumed = it[3] as Boolean, - isFullscreen = it[3] as Boolean, - progress = it[4] as Progress, - ) - } - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/DefaultRootComponent.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/DefaultRootComponent.kt index 21870c8..c6c3d87 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/DefaultRootComponent.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/DefaultRootComponent.kt @@ -5,6 +5,7 @@ import com.arkivanov.decompose.router.stack.* import com.arkivanov.decompose.value.Value import com.corner.catvod.enum.bean.Vod import com.corner.catvodcore.viewmodel.GlobalModel +import com.corner.database.History import com.corner.ui.decompose.component.* import kotlinx.serialization.Serializable diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/DetailComponent.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/DetailComponent.kt index c9621e9..e2f6327 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/DetailComponent.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/DetailComponent.kt @@ -2,16 +2,29 @@ package com.corner.ui.decompose import com.arkivanov.decompose.value.MutableValue import com.corner.catvod.enum.bean.Vod +import com.corner.catvodcore.bean.Episode +import com.corner.catvodcore.bean.Result +import com.corner.catvodcore.bean.Url +import com.corner.database.History +import com.corner.ui.player.vlcj.VlcjFrameController import java.util.concurrent.CopyOnWriteArrayList interface DetailComponent { val model: MutableValue + + val controller:VlcjFrameController + data class Model( var siteKey:String = "", var detail:Vod? = null, var quickSearchResult:CopyOnWriteArrayList = CopyOnWriteArrayList(), - var isLoading: Boolean = false + var isLoading: Boolean = false, + var currentPlayUrl:String = "", + var currentEp: Episode? = null, + var showEpChooserDialog:Boolean = false, + var shouldPlay:Boolean = false, + val currentUrl:Url? = null ) fun load() @@ -27,4 +40,11 @@ interface DetailComponent { fun getChooseVod(): Vod fun setDetail(vod: Vod) + fun play(result: Result?) + fun startPlay() + fun nextEP() + fun playEp(detail: Vod, ep: Episode) + fun updateHistory(it:History?) + fun nextFlag() + fun syncHistory() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/RootComponent.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/RootComponent.kt index 75f7f16..fce7e4a 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/RootComponent.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/RootComponent.kt @@ -3,6 +3,7 @@ package com.corner.ui.decompose import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.value.Value import com.corner.catvod.enum.bean.Vod +import com.corner.database.History import com.corner.ui.decompose.component.DefaultHistoryComponent import com.corner.ui.decompose.component.DefaultSearchPagesComponent import com.corner.ui.decompose.component.DefaultSettingComponent @@ -26,7 +27,6 @@ interface RootComponent{ fun onClickBack() - sealed class Child{ class VideoChild(val component:DefaultVideoComponent):Child() diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/VideoComponent.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/VideoComponent.kt index f92e918..c1dcec0 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/VideoComponent.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/VideoComponent.kt @@ -16,6 +16,8 @@ interface VideoComponent { fun loadMore() + fun clickFolder(vod:Vod) + fun chooseCate(cate: String) fun clear() @@ -56,7 +58,7 @@ interface VideoComponent { homeVodResult.forEach{result = 31 * result + it.hashCode()} result = 31 * result + homeLoaded.hashCode() result = 31 * result + classList.hashCode() - classList.forEach{ result = 31 * result + it.hashCode()} +// classList.forEach{ result = 31 * result + it.hashCode()} result = 31 * result + filtersMap.hashCode() result = 31 * result + (currentClass?.hashCode() ?: 0) result = 31 * result + (currentClass?.hashCode() ?: 0) diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultDetailComponent.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultDetailComponent.kt index 7faa952..06cc624 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultDetailComponent.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultDetailComponent.kt @@ -5,17 +5,26 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.update import com.arkivanov.essenty.lifecycle.Lifecycle +import com.corner.bean.SettingStore import com.corner.catvod.enum.bean.Vod import com.corner.catvod.enum.bean.Vod.Companion.getPage import com.corner.catvod.enum.bean.Vod.Companion.isEmpty import com.corner.catvodcore.bean.Episode +import com.corner.catvodcore.bean.Result import com.corner.catvodcore.bean.detailIsEmpty +import com.corner.catvodcore.bean.v import com.corner.catvodcore.config.ApiConfig +import com.corner.catvodcore.util.Utils import com.corner.catvodcore.viewmodel.GlobalModel +import com.corner.database.Db +import com.corner.database.History import com.corner.ui.decompose.DetailComponent +import com.corner.ui.getPlayerSetting +import com.corner.ui.player.vlcj.VlcjFrameController import com.corner.ui.scene.SnackBar import com.corner.ui.scene.hideProgress import com.corner.ui.scene.showProgress +import com.corner.util.Constants import com.corner.util.cancelAll import kotlinx.coroutines.* import kotlinx.coroutines.sync.Semaphore @@ -45,19 +54,53 @@ class DefaultDetailComponent(componentContext: ComponentContext) : DetailCompone override val model: MutableValue = _model + override val controller: VlcjFrameController = VlcjFrameController(this) + init { lifecycle.subscribe(object : Lifecycle.Callbacks { + override fun onCreate() { + controller.init() + } override fun onStop() { log.info("Detail onStop") - searchScope.cancel("on stop") - fromSearchLoadJob.cancel("on stop") - hideProgress() - clear() + updateHistory(controller.history.value) super.onStop() } + + override fun onDestroy() { + log.info("Detail onDestroy") + super.onDestroy() + SiteViewModel.viewModelScope.launch { + searchScope.cancel("on stop") + fromSearchLoadJob.cancel("on stop") + hideProgress() + clear() + } + controller.dispose() + } }) + +// scope.launch { +// controller?.history?.collect { +// updateHistory(it) +// } +// } } + override fun updateHistory(it:History?) { + if (it != null && StringUtils.isNotBlank(model.value.detail?.site?.key)) { + Db.History.updateSome( + it.vodFlag ?: "", + it.vodRemarks ?: "", + it.episodeUrl ?: "", + it.position ?: -1, + it.speed?.toFloat() ?: 1f, + it.opening ?: -1L, + it.ending ?: -1L, + Utils.getHistoryKey(model.value.detail?.site?.key!!, model.value.detail?.vodId!!) + ) + } + } override fun load() { val chooseVod = getChooseVod() @@ -82,7 +125,7 @@ class DefaultDetailComponent(componentContext: ComponentContext) : DetailCompone detail.copy(subEpisode = detail.currentFlag?.episodes?.getPage(detail.currentTabIndex)) if (StringUtils.isNotBlank(getChooseVod().vodRemarks)) { for (it: Episode in detail.subEpisode ?: listOf()) { - if (it.name.equals(getChooseVod().vodRemarks)) { + if (it.name == getChooseVod().vodRemarks) { it.activated = true break } @@ -90,6 +133,7 @@ class DefaultDetailComponent(componentContext: ComponentContext) : DetailCompone } detail.site = getChooseVod().site model.update { it.copy(detail = detail) } + startPlay() } } } @@ -185,19 +229,11 @@ class DefaultDetailComponent(componentContext: ComponentContext) : DetailCompone if (model.value.quickSearchResult.isNotEmpty()) loadDetail(model.value.quickSearchResult[0]) } - fun buildVodList(): List { - val list = mutableListOf() - for (i in 0 until 30) { - list.add(Vod(vodId = "$i", vodName = "name$i", vodRemarks = "remark$i")) - } - return list - } - override fun clear() { launched = false jobList.forEach { it.cancel("detail clear") } jobList.clear() - model.update { it.copy(quickSearchResult = CopyOnWriteArrayList(), detail = null) } + model.update { it.copy(quickSearchResult = CopyOnWriteArrayList(), detail = null, showEpChooserDialog = false) } SiteViewModel.clearQuickSearch() } @@ -210,5 +246,129 @@ class DefaultDetailComponent(componentContext: ComponentContext) : DetailCompone SnackBar.postMsg("正在切换站源至 [${vod.site!!.name}]") } model.update { it.copy(detail = vod) } + startPlay() + } + + override fun play(result: Result?) { + //获取到的播放结果为空 尝试下一个线路 + if (result == null) { + nextFlag() + return + } + model.update { it.copy(currentPlayUrl = result.url.v()) } + } + + override fun startPlay() { + if (model.value.detail != null && model.value.detail?.isEmpty() == false) { + if (controller.isPlaying() == true && !model.value.shouldPlay) { + log.info("视频播放中 返回") + return + } + model.value.shouldPlay = false +// val internalPlayer: Boolean = SettingStore.getPlayerSetting()[0] as Boolean +// if(!internalPlayer) return // 如果使用外部播放器直接返回 + log.info("start play") + val detail = model.value.detail + var findEp: Episode? = null + if (detail == null || detail.isEmpty()) return + var history = Db.History.findHistory(Utils.getHistoryKey(detail.site?.key!!, detail.vodId)) + if (history == null) Db.History.create(detail, detail.currentFlag?.flag!!, detail.vodName!!) + else { + if (model.value.currentEp != null && !model.value.currentEp?.name.equals(history.vodRemarks) && history.position != null) { + history = history.copy(position = 0L) + } + controller.setStartEnd(history.opening ?: -1, history.ending ?: -1) + + findEp = detail.findAndSetEpByName(history) + model.update { it.copy(detail = detail) } + } + val findHistory = Db.History.findHistory( + Utils.getHistoryKey(detail.site?.key!!, detail.vodId) + ) + if(findHistory != null){ + controller.setControllerHistory(findHistory) + } + detail.subEpisode?.apply { + val ep = findEp ?: first() + playEp(detail, ep) + } + } + } + + override fun playEp(detail: Vod, ep: Episode) { + val result = SiteViewModel.playerContent( + detail.site?.key ?: "", + detail.currentFlag?.flag ?: "", + ep.url + ) + model.update { it.copy(currentUrl = result?.url) } + if (result == null) { + nextFlag() + return + } + controller.doWithHistory { it.copy(episodeUrl = ep.url) } + val internalPlayer = SettingStore.getPlayerSetting()[0] as Boolean + if(internalPlayer){ + model.update { it.copy(currentPlayUrl = result.url.v(), currentEp = ep) } + } + detail.subEpisode?.parallelStream()?.forEach { + it.activated = it == ep + } + if(!internalPlayer){ + SnackBar.postMsg("上次播放" + ": ${ep.name}") + } + } + + override fun nextEP() { + log.info("下一集") + var detail = model.value.detail + var nextIndex = 0 + var currentIndex = 0 + val currentEp = detail?.subEpisode?.find { it.activated } + controller.doWithHistory { it.copy(position = 0) } + if (currentEp != null) { + currentIndex = detail?.subEpisode?.indexOf(currentEp)!! + nextIndex = currentIndex+1 + } + if (currentIndex >= Constants.EpSize - 1) { + log.info("当前分组播放完毕 下一个分组") + detail = + detail?.copy(subEpisode = detail.currentFlag?.episodes?.getPage(++detail.currentTabIndex)) + nextIndex = 0 + model.update { it.copy(detail = detail) } + } + detail?.subEpisode?.get(nextIndex)?.let { + playEp(detail, it) + } + } + + override fun nextFlag() { + log.info("nextFlag") + model.value.detail?.currentFlag = model.value.detail?.nextFlag() + if (model.value.detail?.currentFlag == null) { + model.value.detail?.vodId = "" // 清空id 快搜就会重新加载详情 + quickSearch() + return + } + SnackBar.postMsg("切换至线路[${model.value.detail?.currentFlag?.flag}]") + controller.doWithHistory { it.copy(vodFlag = model.value.detail?.currentFlag?.flag) } + GlobalModel.chooseVod.value = model.value.detail!! + model.update { it.copy(detail = model.value.detail) } + } + + override fun syncHistory() { + val detail = model.value.detail ?: return + var history = Db.History.findHistory(Utils.getHistoryKey(detail.site?.key!!, detail.vodId)) + if (history == null) Db.History.create(detail, detail.currentFlag?.flag!!, detail.vodName!!) + else { + if (!model.value.currentEp?.name.equals(history.vodRemarks) && history.position != null) { + history = history.copy(position = 0L) + } + controller.setControllerHistory(history) + controller.setStartEnd(history.opening ?: -1, history.ending ?: -1) + + val findEp = detail.findAndSetEpByName(history) + model.update { it.copy(detail = detail, currentEp = findEp, currentPlayUrl = findEp?.url ?: "") } + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultSettingComponent.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultSettingComponent.kt index fd3e98e..5945231 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultSettingComponent.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultSettingComponent.kt @@ -3,7 +3,9 @@ package com.corner.ui.decompose.component import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.update +import com.corner.bean.Setting import com.corner.bean.SettingStore +import com.corner.bean.SettingType import com.corner.ui.decompose.SettingComponent class DefaultSettingComponent(componentContext: ComponentContext):SettingComponent, ComponentContext by componentContext { @@ -13,4 +15,8 @@ class DefaultSettingComponent(componentContext: ComponentContext):SettingCompone override fun sync() { _model.update { it.copy(settingList = SettingStore.getSettingList(), version = _model.value.version + 1) } } +} + +fun List.getSetting(type:SettingType):Setting?{ + return this.find { it.id == type.id } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultVideoComponent.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultVideoComponent.kt index 30921a5..00138f7 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultVideoComponent.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/decompose/component/DefaultVideoComponent.kt @@ -6,9 +6,11 @@ import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.update import com.arkivanov.essenty.backhandler.BackHandlerOwner import com.arkivanov.essenty.lifecycle.Lifecycle +import com.corner.catvod.enum.bean.Vod import com.corner.catvodcore.bean.Filter import com.corner.catvodcore.bean.Type import com.corner.catvodcore.bean.getFirstOrEmpty +import com.corner.catvodcore.config.ApiConfig import com.corner.catvodcore.viewmodel.GlobalModel import com.corner.ui.decompose.VideoComponent import com.corner.ui.scene.hideProgress @@ -153,6 +155,22 @@ class DefaultVideoComponent(componentContext: ComponentContext) : VideoComponent } } + override fun clickFolder(vod: Vod) { + showProgress() + SiteViewModel.viewModelScope.launch { + val result = SiteViewModel.categoryContent( + ApiConfig.api.recent!!, + vod.vodId, + "1", + false, + hashMapOf() + ) + model.update { it.copy(homeVodResult = result.list.toMutableSet()) } + }.invokeOnCompletion { + hideProgress() + } + } + override fun loadMore() { if (model.value.currentClass == null || model.value.currentClass?.typeId == "home") return if ((model.value.currentClass?.failTime ?: 0) >= 2) return @@ -171,13 +189,17 @@ class DefaultVideoComponent(componentContext: ComponentContext) : VideoComponent extend ) if (!rst.isSuccess || rst.list.isEmpty()) { - model.value.currentClass?.failTime?.plus(1) + model.value.currentClass?.failTime = model.value.currentClass?.failTime!! + 1 return@launch } val list = rst.list - val vodList = model.value.homeVodResult.toMutableList() - vodList.addAll(list) - model.update { it.copy(homeVodResult = vodList.toSet().toMutableSet()) } + // 有的源不支持分页 每次请求返回相同的数据 + if(model.value.homeVodResult.map { it.vodId }.containsAll(list.map { it.vodId })){ + model.value.currentClass?.failTime = model.value.currentClass?.failTime!! + 1 + return@launch + } + model.value.homeVodResult.addAll(list) + model.update { it.copy(homeVodResult = model.value.homeVodResult) } } finally { } }.invokeOnCompletion { @@ -212,6 +234,7 @@ class DefaultVideoComponent(componentContext: ComponentContext) : VideoComponent fun getFilters(type: Type): Filter { val filters = model.value.filtersMap[type.typeId] ?: return Filter.ALL + // todo 这里可有多个Filter 需要修改页面 可以显示多个Filter return filters.getFirstOrEmpty() } diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/player/DefaultControls.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/player/DefaultControls.kt new file mode 100644 index 0000000..ae98d12 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/player/DefaultControls.kt @@ -0,0 +1,181 @@ +package com.corner.ui.player + +import AppTheme +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.VolumeDown +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.value.update +import com.corner.catvodcore.util.Utils +import com.corner.ui.decompose.DetailComponent +import com.corner.ui.player.vlcj.VlcjFrameController +import com.corner.util.formatTimestamp +import org.jetbrains.compose.ui.tooling.preview.Preview +import kotlin.math.roundToLong + +@Composable +fun DefaultControls(modifier: Modifier = Modifier, controller: VlcjFrameController, component: DetailComponent) { + + val state by controller.state.collectAsState() + + val animatedTimestamp by animateFloatAsState(state.timestamp.toFloat()) + + Column( + modifier.padding(horizontal = 8.dp, vertical = 1.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.SpaceBetween + ) { + Slider( + value = animatedTimestamp, + onValueChange = { controller.seekTo(it.roundToLong()) }, + valueRange = 0f..state.duration.toFloat(), + modifier = Modifier.fillMaxWidth().height(15.dp).padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 20.dp), + colors = SliderDefaults.colors(thumbColor = MaterialTheme.colorScheme.primary, activeTrackColor = MaterialTheme.colorScheme.secondary, disabledActiveTrackColor = MaterialTheme.colorScheme.tertiary) + ) + Row( + Modifier.fillMaxWidth().height(50.dp).padding(bottom = 5.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextButtonTransparent(if(state.opening == -1L) "片头" else Utils.formatMilliseconds(state.opening)){ + controller.updateOpening(component.model.value.detail) + } + TextButtonTransparent(if(state.ending == -1L) "片尾" else Utils.formatMilliseconds(state.ending)){ + controller.updateEnding(component.model.value.detail) + } + + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + Text("${state.timestamp.formatTimestamp()} / ${state.duration.formatTimestamp()}", + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + ) + } + if (state.isPlaying) { + IconButton(controller::pause) { + Icon(Icons.Rounded.Pause, "pause media", tint = MaterialTheme.colorScheme.primary) + } + } else { + IconButton(controller::play) { + Icon(Icons.Rounded.PlayArrow, "play media", tint = MaterialTheme.colorScheme.primary) + } + } + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (state.isMuted || state.volume == 0f) IconButton(controller::toggleSound) { + Icon(Icons.AutoMirrored.Rounded.VolumeOff, "volume off", tint = MaterialTheme.colorScheme.primary) + } + else { + if (state.volume < .5f) IconButton(controller::toggleSound) { + Icon(Icons.AutoMirrored.Rounded.VolumeDown, "volume low", tint = MaterialTheme.colorScheme.primary) + } else IconButton(controller::toggleSound) { + Icon(Icons.AutoMirrored.Rounded.VolumeUp, "volume high", tint = MaterialTheme.colorScheme.primary ) + } + } + Slider( + value = state.volume, + onValueChange = controller::setVolume, + modifier = Modifier.width(128.dp), + valueRange = 0f..1.5f, + colors = SliderDefaults.colors(thumbColor = MaterialTheme.colorScheme.primary, activeTrackColor = MaterialTheme.colorScheme.secondary, disabledActiveTrackColor = MaterialTheme.colorScheme.tertiary) + ) + Spacer(Modifier.size(5.dp)) + Speed(initialValue = state.speed, Modifier.width(85.dp).height(45.dp)) { controller.speed(it ?: 1F) } + IconButton({controller.toggleFullscreen()}){ + Icon(Icons.Default.Fullscreen, contentDescription = "fullScreen/UnFullScreen", tint = MaterialTheme.colorScheme.primary) + } + TextButtonTransparent("选集"){ + component.model.update { it.copy(showEpChooserDialog = !component.model.value.showEpChooserDialog) } + println("选集") + } + } + } + } + } +} + +@Composable +fun TextButtonTransparent(text:String, onClick:()->Unit){ + androidx.compose.material3.TextButton(onClick = onClick, + shape = RoundedCornerShape(50), + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.primary) + ){ + Text(text, color = MaterialTheme.colorScheme.primary, fontSize = TextUnit(12f, TextUnitType.Sp)) + } +} + +@Preview +@Composable +fun previewControlBar(){ + AppTheme { +// DefaultControls(Modifier, VlcjFrameController(), null) + } +} + +/** + * See [this Stack Overflow post](https://stackoverflow.com/a/67765652). + */ +@Composable +fun Speed( + initialValue: Float, + modifier: Modifier = Modifier, + onChange: (Float?) -> Unit +) { + var input by remember { mutableStateOf(initialValue.toString()) } + OutlinedTextField( + value = input, + + modifier = modifier.padding(0.dp), + singleLine = true, + textStyle = TextStyle.Default.copy(color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, + fontSize = TextUnit(12f, TextUnitType.Sp)), + leadingIcon = { + Icon( + painter = painterResource("pic/speed.svg"), + contentDescription = "Speed", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + onValueChange = { + input = if (it.isEmpty()) { + it + } else if (it.toFloatOrNull() == null) { + input // Old value + } else { + it // New value + } + onChange(input.toFloatOrNull()) + }, + ) +} + +@androidx.compose.desktop.ui.tooling.preview.Preview +@Composable +fun previewSpeed(){ + Speed(1f, Modifier.width(85.dp).height(45.dp), onChange = {}) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/player/MediaPlayer.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/player/MediaPlayer.kt new file mode 100644 index 0000000..879e3f2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/player/MediaPlayer.kt @@ -0,0 +1,5 @@ +package com.corner.ui.player + +interface OnTimeChangedListener { + fun onTimeChanged(timeMillis: Long) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/player/PlayerController.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/player/PlayerController.kt new file mode 100644 index 0000000..0c6b747 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/player/PlayerController.kt @@ -0,0 +1,55 @@ +package com.corner.ui.player + +import com.corner.catvod.enum.bean.Vod +import com.corner.database.History +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer + +interface PlayerController { + val state: StateFlow + + var showTip: MutableStateFlow + var tip: MutableStateFlow + + var history:MutableStateFlow + + fun load(url: String): PlayerController + + fun onMediaPlayerReady(mediaPlayer: EmbeddedMediaPlayer) + + fun doWithMediaPlayer(block: (MediaPlayer) -> Unit) + + fun play() + + fun play(url:String) + fun pause() + fun stop() + fun dispose() + fun seekTo(timestamp: Long) + fun setVolume(value: Float) + + fun volumeUp() + + fun volumeDown() + fun toggleSound() + + fun toggleFullscreen() + + fun speed(speed: Float) + + fun stopForward() + + fun fastForward() + fun togglePlayStatus() + fun init() + fun backward(time: String = "15s") + fun forward(time: String = "15s") + + fun updateOpening(detail: Vod?) + + fun updateEnding(detail: Vod?) + fun setStartEnding(opening: Long, ending: Long) + fun doWithPlayState(func: (MutableStateFlow) -> Unit) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/player/PlayerState.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/player/PlayerState.kt new file mode 100644 index 0000000..d63283f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/player/PlayerState.kt @@ -0,0 +1,21 @@ +package com.corner.ui.player + +data class PlayerState( + val isPlaying: Boolean = false, + val isBuffering:Boolean = false, + val isMuted: Boolean = false, + var isFullScreen: Boolean = false, + val volume: Float = .5f, + val timestamp: Long = 0L, + val duration: Long = 0L, + val speed: Float = 1F, + var opening:Long = -1, + val ending:Long = -1, + val mediaInfo: MediaInfo? = null, +) + +data class MediaInfo( + val height:Int, + val width:Int, + val url:String +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/player/frame/FrameContainer.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/player/frame/FrameContainer.kt new file mode 100644 index 0000000..a9bb2a6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/player/frame/FrameContainer.kt @@ -0,0 +1,101 @@ +package com.corner.ui.player.frame + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.isTypedEvent +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import com.corner.catvodcore.viewmodel.GlobalModel +import com.corner.ui.player.vlcj.VlcjFrameController +import uk.co.caprica.vlcj.player.base.State + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +fun FrameContainer( + modifier: Modifier = Modifier, + controller: VlcjFrameController, + onClick:()->Unit +) { + val playerState = controller.state.collectAsState() + val bitmap by remember { controller.imageBitmapState } + val interactionSource = remember { MutableInteractionSource() } + Box(modifier = modifier.background(Color.Black) + .combinedClickable( + enabled = true, + onDoubleClick = { + controller.togglePlayStatus() + }, + interactionSource = interactionSource, + indication = null + ) { + // onClick + onClick() + } + .onPointerEvent(PointerEventType.Scroll) { e -> + val y = e.changes.first().scrollDelta.y + if (y < 0) { + controller.volumeUp() + } else { + controller.volumeDown() + } + } + .onKeyEvent { k -> + when (k.key) { + Key.DirectionRight -> { + if (k.type == KeyEventType.KeyDown) { + controller.fastForward() + } else if (k.type == KeyEventType.KeyUp) { + controller.stopForward() + } + if (k.isTypedEvent) { + controller.forward() + } + } + + Key.DirectionLeft -> { + if (k.isTypedEvent) { + controller.backward() + } + } + + Key.Spacebar -> if (k.type == KeyEventType.KeyDown) controller.togglePlayStatus() + Key.DirectionUp -> if (k.type == KeyEventType.KeyDown) controller.volumeUp() + Key.DirectionDown -> if (k.type == KeyEventType.KeyDown) controller.volumeDown() + Key.Escape -> if (k.type == KeyEventType.KeyDown && GlobalModel.videoFullScreen.value) controller.toggleFullscreen() + } + true + }, contentAlignment = Alignment.Center + ) { + if(bitmap != null){ + bitmap?.let { + Image(bitmap = it, contentDescription = "Video", modifier = Modifier.fillMaxSize()) + } + }else{ + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + if(playerState.value.isBuffering){ + androidx.compose.material3.CircularProgressIndicator(Modifier.align(Alignment.Center)) + }else { + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource("/pic/TV-icon-x.png"), + contentDescription = "nothing here", + contentScale = ContentScale.Crop + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/player/frame/FrameRenderer.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/player/frame/FrameRenderer.kt new file mode 100644 index 0000000..c3e4188 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/player/frame/FrameRenderer.kt @@ -0,0 +1,8 @@ +package com.corner.ui.player.frame + +import kotlinx.coroutines.flow.StateFlow + +interface FrameRenderer { + val size: StateFlow> + val bytes: StateFlow +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/player/vlcj/VlcJInit.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/player/vlcj/VlcJInit.kt new file mode 100644 index 0000000..916e345 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/player/vlcj/VlcJInit.kt @@ -0,0 +1,28 @@ +package com.corner.ui.player.vlcj + +import com.corner.ui.scene.SnackBar +import com.corner.util.Constants +import com.sun.jna.NativeLibrary +import org.apache.commons.lang3.StringUtils +import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil +import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery +import java.nio.file.Paths +import kotlin.io.path.pathString + +class VlcJInit { + companion object{ + fun init(){ + val resourcePath = System.getProperty(Constants.resPathKey) + if(StringUtils.isNotBlank(resourcePath)){ + NativeLibrary.addSearchPath( + RuntimeUtil.getLibVlcLibraryName(), + Paths.get(resourcePath, "lib").pathString + ) + }else{ + println("VlcJInit 未找到${Constants.resPathKey}环境变量") + } + val discover = NativeDiscovery().discover() + if(!discover) SnackBar.postMsg("未找到VLC组件, 请安装VLC或者配置vlc可执行文件位置") + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/player/vlcj/VlcjController.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/player/vlcj/VlcjController.kt new file mode 100644 index 0000000..6f190be --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/player/vlcj/VlcjController.kt @@ -0,0 +1,376 @@ +package com.corner.ui.player.vlcj + +import com.corner.bean.PlayerStateCache +import com.corner.bean.SettingStore +import com.corner.catvod.enum.bean.Vod +import com.corner.catvodcore.viewmodel.GlobalModel +import com.corner.database.History +import com.corner.ui.decompose.DetailComponent +import com.corner.ui.player.MediaInfo +import com.corner.ui.player.PlayerController +import com.corner.ui.player.PlayerState +import com.corner.ui.scene.SnackBar +import com.corner.util.catch +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.apache.commons.lang3.StringUtils +import org.slf4j.LoggerFactory +import uk.co.caprica.vlcj.factory.MediaPlayerFactory +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter +import uk.co.caprica.vlcj.player.base.State +import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer +import kotlin.time.Duration +import kotlin.time.DurationUnit + +private val log = LoggerFactory.getLogger("PlayerController") + +class VlcjController(val component: DetailComponent) : PlayerController { + var player: EmbeddedMediaPlayer? = null + private set + private val defferredEffects = mutableListOf<(MediaPlayer) -> Unit>() + + private var isAccelerating = false + private var originSpeed = 1.0F + private var currentSpeed = 1.0F + private var playerReady = false + + override var showTip = MutableStateFlow(false) + override var tip = MutableStateFlow("") + override var history: MutableStateFlow = MutableStateFlow(null) + var scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val vlcjArgs = listOf( + "--no-video-title-show", // 禁用视频标题显示 + "--no-snapshot-preview", // 禁用快照预览 + "--no-autoscale", // 禁用自动缩放 + "--no-disable-screensaver", // 禁用屏保 + "--avcodec-fast", // 使用快速解码模式 + "--network-caching=3000", // 设置网络缓存为 3000ms + "--file-caching=3000", // 设置文件缓存为 3000ms + "--live-caching=3000", // 设置直播缓存为 3000ms + "--sout-mux-caching=3000" // 设置输出缓存为 3000ms + ) + + internal lateinit var factory:MediaPlayerFactory + + override fun doWithMediaPlayer(block: (MediaPlayer) -> Unit) { + player?.let { + block(it) + } ?: run { + defferredEffects.add(block) + } + } + + override fun onMediaPlayerReady(mediaPlayer: EmbeddedMediaPlayer) { + this.player = mediaPlayer + _state.update { it.copy(duration = player?.status()?.length() ?: 0L) } + defferredEffects.forEach { block -> + block(mediaPlayer) + } + defferredEffects.clear() + } + + val stateListener = object : MediaPlayerEventAdapter() { + override fun mediaPlayerReady(mediaPlayer: MediaPlayer) { + log.info("播放器初始化完成") + playerReady = true + _state.update { it.copy(duration = mediaPlayer.status().length()) } + play() + } + + override fun videoOutput(mediaPlayer: MediaPlayer?, newCount: Int) { + + val trackInfo = mediaPlayer?.media()?.info()?.videoTracks()?.first() + if(trackInfo != null){ + _state.update { it.copy(mediaInfo = MediaInfo(url = mediaPlayer.media()?.info()?.mrl() ?: "", height = trackInfo.height(), width = trackInfo.width())) } + } + } + + override fun buffering(mediaPlayer: MediaPlayer?, newCache: Float) { + _state.update { it.copy(isBuffering = newCache != 100F) } + } + + override fun opening(mediaPlayer: MediaPlayer?) { + _state.update { it.copy(isBuffering = true) } + } + + + override fun playing(mediaPlayer: MediaPlayer) { + _state.update { it.copy(isPlaying = true) } + } + + override fun paused(mediaPlayer: MediaPlayer) { + _state.update { it.copy(isPlaying = false) } + } + + override fun stopped(mediaPlayer: MediaPlayer) { + println("stopped") + _state.update { it.copy(isPlaying = false) } + } + + override fun finished(mediaPlayer: MediaPlayer) { + println("finished") + _state.update { it.copy(isPlaying = false) } + scope.launch { + try { + if (checkEnd(mediaPlayer)) { + return@launch + } + component.nextEP() + } catch (e: Exception) { + log.error("finished error", e) + } + } + } + + override fun muted(mediaPlayer: MediaPlayer, muted: Boolean) { + _state.update { it.copy(isMuted = muted) } + } + + override fun volumeChanged(mediaPlayer: MediaPlayer, volume: Float) { + if (volume > 0f) { + SettingStore.doWithCache { + var state = it["playerState"] + if (state == null) { + state = PlayerStateCache() + it["playerState"] = state + } + (state as PlayerStateCache).add("volume", volume.toString()) + } + } + _state.update { it.copy(volume = volume) } + } + + override fun timeChanged(mediaPlayer: MediaPlayer, newTime: Long) { + scope.launch { + if (history.value == null) { + println("histiry is null") + return@launch + } + if (history.value?.ending != null && history.value?.ending != -1L && history.value?.ending!! <= newTime) component.nextEP() + if ((newTime / 1000 % 25).toInt() == 0) history.emit(history.value?.copy(position = newTime)) + } + _state.update { it.copy(timestamp = newTime) } + } + + override fun error(mediaPlayer: MediaPlayer?) { + log.error("播放错误: ${mediaPlayer?.media()?.info()?.mrl()}") + scope.launch { + try { + if (checkEnd(mediaPlayer)) { + return@launch + } + SnackBar.postMsg("播放错误") + } catch (e: Exception) { + log.error("error ", e) + } + } + super.error(mediaPlayer) + } + + private fun checkEnd(mediaPlayer: MediaPlayer?): Boolean { + try { + val len = mediaPlayer?.status()?.length() ?: 0 + println("playable" + mediaPlayer?.status()?.isPlayable) +// if (len <= 0 /*|| mediaPlayer?.status()?.time() != len*/ || mediaPlayer?.status()?.isPlayable == false) { +// component.nextFlag() +// return true +// } + return false + } catch (e: Exception) { + log.error("checkEnd error:", e) + return false + } + } + } + + private val _state = MutableStateFlow(PlayerState()) + + override val state: StateFlow + get() = _state.asStateFlow() + + override fun init() { + catch { +// dispose() + factory = MediaPlayerFactory(vlcjArgs) + player = factory.mediaPlayers()?.newEmbeddedMediaPlayer()?.apply { + events().addMediaPlayerEventListener(stateListener) + video().setScale(1.0f) + } + } + } + + override fun load(url: String): PlayerController { + log.debug("加载:$url") + if (StringUtils.isBlank(url)) { + return this + } + catch { + player?.media()?.prepare(url) + } + return this + } + + private val stateList = listOf(State.ENDED, State.ERROR) + override fun play() { + catch { + log.debug("play") + showTips("播放") + if (stateList.contains(player?.status()?.state())) { + val mrl = player?.media()?.info()?.mrl() + if (StringUtils.isNotBlank(mrl)) { + load(mrl!!) + component.syncHistory() + } else { + log.error("视频播放完毕或者播放错误, 重新加载时 url为空") + } + } + player?.controls()?.play() + } + } + + override fun play(url: String) = catch { + showTips("播放") + log.debug("play: $url") + player?.media()?.play(url) + } + + override fun pause() = catch { + showTips("暂停") + player?.controls()?.setPause(true) + } + + private fun showTips(text: String) { + runBlocking { + tip.emit(text) + showTip.emit(true) + } + } + + override fun stop() = catch { + showTips("停止") + player?.controls()?.stop() + } + + override fun dispose() = catch { + log.debug("dispose") + stop() + player?.release() + factory.release() + } + + override fun seekTo(timestamp: Long) = catch { + player?.controls()?.setTime(timestamp) + } + + override fun setVolume(value: Float) = catch { + player?.audio()?.setVolume((value * 100).toInt().coerceIn(0..150)) + showTips("音量:${player?.audio()?.volume()}") + } + + private val volumeStep = 5 + + override fun volumeUp() { + player?.audio()?.setVolume((((player?.audio()?.volume() ?: 0) + volumeStep).coerceIn(0..150))) + showTips("音量:${player?.audio()?.volume()}") + } + + override fun volumeDown() { + player?.audio()?.setVolume((((player?.audio()?.volume() ?: 0) - volumeStep).coerceIn(0..150))) + showTips("音量:${player?.audio()?.volume()}") + } + + /** + * 快进 单位 秒 + */ + override fun forward(time: String) { + showTips("快进:$time") + player?.controls()?.skipTime(Duration.parse(time).toLong(DurationUnit.MILLISECONDS)) + } + + override fun backward(time: String) { + showTips("快退:$time") + player?.controls()?.skipTime(-Duration.parse(time).toLong(DurationUnit.MILLISECONDS)) + } + + override fun toggleSound() = catch { + player?.audio()?.mute() + } + + override fun toggleFullscreen() = catch { + val videoFullScreen = GlobalModel.toggleVideoFullScreen() + runBlocking { + if (videoFullScreen) showTips("[ESC]退出全屏") + } + } + + override fun togglePlayStatus() { + if (player?.status()?.isPlaying == true) { + pause() + } else { + play() + } + } + + override fun speed(speed: Float) = catch { + showTips("倍速:$speed") + player?.controls()?.setRate(speed) + } + + override fun stopForward() { + isAccelerating = false + speed(originSpeed) + } + + override fun fastForward() { + if (!isAccelerating) { + currentSpeed = player?.status()?.rate() ?: 1.0f + originSpeed = currentSpeed.toDouble().toFloat() + isAccelerating = true + } + acceleratePlayback() + } + + private val maxSpeed = 8.0f + + private fun acceleratePlayback() { + if (isAccelerating) { + currentSpeed += 0.5f + currentSpeed = Math.min(currentSpeed, maxSpeed) + speed(currentSpeed) + println("Playback rate: $currentSpeed x") + } + } + + override fun updateEnding(detail: Vod?) { + _state.update { it.copy(ending = player?.status()?.time() ?: -1) } +// if (_state.value.ending == -1L) { +// } else { +// _state.update { it.copy(ending = -1) } +// } + history.update { it?.copy(ending = player?.status()?.time() ?: -1) } + } + + override fun updateOpening(detail: Vod?) { + _state.update { it.copy(opening = player?.status()?.time() ?: -1) } +// if (_state.value.opening == -1L) { +// } else { +// _state.update { it.copy(opening = -1) } +// } + history.update { it?.copy(opening = player?.status()?.time() ?: -1) } + } + + override fun doWithPlayState(func: (MutableStateFlow) -> Unit) { + runBlocking { + func(_state) + } + } + + override fun setStartEnding(opening: Long, ending: Long) { + _state.update { it.copy(opening = opening, ending = ending) } + } + +} diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/player/vlcj/VlcjFrameController.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/player/vlcj/VlcjFrameController.kt new file mode 100644 index 0000000..ba8d44a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/player/vlcj/VlcjFrameController.kt @@ -0,0 +1,133 @@ +package com.corner.ui.player.vlcj + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import com.corner.database.History +import com.corner.ui.decompose.DetailComponent +import com.corner.ui.player.PlayerController +import com.corner.ui.player.frame.FrameRenderer +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import org.slf4j.LoggerFactory +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurfaceAdapters +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.nio.ByteBuffer +import kotlin.math.max + + +class VlcjFrameController constructor( + component: DetailComponent, + private val controller: VlcjController = VlcjController(component), +) : FrameRenderer, PlayerController by controller { + private val log = LoggerFactory.getLogger(this::class.java) + + private var byteArray: ByteArray? = null + private var info: ImageInfo? = null + val imageBitmapState: MutableState = mutableStateOf(null) + + private var historyCollectJob: Job? = null + + + private val _size = MutableStateFlow(0 to 0) + override val size = _size.asStateFlow() + + private val _bytes = MutableStateFlow(null) + override val bytes = _bytes.asStateFlow() + + val callbackSurFace = CallbackVideoSurface(object : BufferFormatCallback { + override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat { + info = ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.OPAQUE) + return RV32BufferFormat(sourceWidth, sourceHeight) + } + + override fun allocatedBuffers(buffers: Array) { + byteArray = ByteArray(buffers[0].limit()) + } + }, object : RenderCallback { + override fun display( + mediaPlayer: MediaPlayer, + nativeBuffers: Array, + bufferFormat: BufferFormat? + ) { + val byteBuffer = nativeBuffers[0] + + byteBuffer.get(byteArray) + byteBuffer.rewind() + + val bmp = Bitmap() + bmp.allocPixels(info!!) + bmp.installPixels(byteArray) + imageBitmapState.value = bmp.asComposeImageBitmap() + } + }, true, + VideoSurfaceAdapters.getVideoSurfaceAdapter() + ) + + override fun load(url: String): PlayerController { + controller.load(url) + speed(controller.history.value?.speed?.toFloat() ?: 1f) + controller.stop() +// if(controller.player?.status()?.isPlaying == true){ +// } + controller.play() + seekTo(max(controller.history.value?.position ?: 0L, history.value?.opening ?: 0L)) + return controller + } + + override fun init() { + log.info("播放器初始化") + controller.init() + controller.player?.videoSurface()?.set(callbackSurFace) + } + + fun isPlaying(): Boolean { + return controller.player?.status()?.isPlayable == true && controller.player?.status()?.isPlaying == true + } + + fun setStartEnd(opening: Long, ending: Long) { + controller.setStartEnding(opening, ending) + } + + fun setControllerHistory(history: History) { + controller.scope.launch { + controller.history.emit(history) + } + if(historyCollectJob != null) return + historyCollectJob = controller.scope.launch { + delay(10) + controller.history.collect { + controller.component.updateHistory(it) + } + } + } + + fun getControllerHistory(): History? { + return controller.history.value + } + + fun doWithHistory(func: (History) -> History) { + runBlocking { + if(controller.history.value == null) return@runBlocking + controller.history.emit(func(controller.history.value!!)) + } + } + + fun getPlayer(): MediaPlayer? { + return controller.player + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/scene/ArrowBackBar.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/scene/ArrowBackBar.kt index 8ef0b3f..cb9ba9b 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/scene/ArrowBackBar.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/scene/ArrowBackBar.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp @Composable fun BackRow(modifier: Modifier, onClickBack:()->Unit,content: @Composable ()->Unit){ - Row(modifier = modifier/*.background(MaterialTheme.colorScheme.surface)*/.height(80.dp).fillMaxWidth().padding(start = 20.dp, end = 20.dp), + Row(modifier = modifier/*.background(MaterialTheme.colorScheme.surface)*/.height(60.dp).fillMaxWidth().padding(start = 20.dp, end = 20.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { IconButton( diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/scene/BaseScene.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/scene/BaseScene.kt index 0897205..40a6c25 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/scene/BaseScene.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/scene/BaseScene.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -77,14 +79,29 @@ fun LoadingIndicator(showProgress: Boolean) { } @Composable -fun emptyShow(){ - Column(Modifier.fillMaxWidth()/*.align(Alignment.CenterHorizontally)*/) { +fun emptyShow(modifier: Modifier = Modifier, onRefresh: (() -> Unit)? = null) { + Column(modifier.fillMaxWidth()/*.align(Alignment.CenterHorizontally)*/) { Image( modifier = Modifier.align(Alignment.CenterHorizontally), - painter = painterResource("/icon/nothing.png"), + painter = painterResource("/pic/nothing.png"), contentDescription = "/icon/nothing here", - contentScale = ContentScale.Crop + contentScale = ContentScale.Fit ) + onRefresh?.let { + Spacer(Modifier.size(20.dp)) + Button( + onClick = onRefresh, + Modifier.align(Alignment.CenterHorizontally).size(80.dp), + shape = RoundedCornerShape(9.dp), + colors = ButtonDefaults.buttonColors().copy( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + enabled = !isShowProgress() + ) { + Icon(Icons.Default.Refresh, contentDescription = "refresh", modifier = Modifier.fillMaxSize(0.8f)) + } + } // Text("这里什么都没有...", modifier.align(Alignment.CenterHorizontally)) } } @@ -127,16 +144,16 @@ fun MenuItem( * @param modifier 弹窗窗体的modifier */ @Composable -fun Dialog(modifier: Modifier, - showDialog: Boolean, - onClose: () -> Unit, - enter: EnterTransition = scaleIn() + fadeIn(spring()), - exit: ExitTransition = scaleOut() + fadeOut(spring()), - content: @Composable () -> Unit) { +fun Dialog( + modifier: Modifier, + showDialog: Boolean, + onClose: () -> Unit, + enter: EnterTransition = scaleIn() + fadeIn(spring()), + exit: ExitTransition = scaleOut() + fadeOut(spring()), + content: @Composable () -> Unit +) { AnimatedVisibility( - visible = showDialog, - enter = enter, - exit = exit + visible = showDialog, enter = enter, exit = exit ) { val interactionSource = remember { MutableInteractionSource() } @@ -147,9 +164,7 @@ fun Dialog(modifier: Modifier, }) ) { Surface( - modifier = modifier - .shadow(2.dp, shape = RoundedCornerShape(10.dp)) - .align(Alignment.Center) + modifier = modifier.shadow(2.dp, shape = RoundedCornerShape(10.dp)).align(Alignment.Center) .clickable(enabled = false, onClick = {}), shape = RoundedCornerShape(15.dp), border = BorderStroke(2.dp, Color.Gray) @@ -162,7 +177,7 @@ fun Dialog(modifier: Modifier, @OptIn(ExperimentalFoundationApi::class) @Composable -fun ToolTipText(text: String, textStyle: TextStyle, delayMills:Int = 600, modifier: Modifier = Modifier) { +fun ToolTipText(text: String, textStyle: TextStyle, delayMills: Int = 600, modifier: Modifier = Modifier) { TooltipArea( tooltip = { // composable tooltip content @@ -177,15 +192,10 @@ fun ToolTipText(text: String, textStyle: TextStyle, delayMills:Int = 600, modifi color = MaterialTheme.colorScheme.onSurface, ) } - }, - delayMillis = delayMills + }, delayMillis = delayMills ) { Text( - text = text, - maxLines = 1, - style = textStyle, - overflow = TextOverflow.Ellipsis, - modifier = modifier + text = text, maxLines = 1, style = textStyle, overflow = TextOverflow.Ellipsis, modifier = modifier ) } } @@ -207,10 +217,7 @@ fun HoverableText(text: String, style: TextStyle = TextStyle(), onClick: () -> U onClick() }, style = style, - modifier = Modifier - .padding(8.dp) - .background(Color.Transparent) - .hoverable(interactionSource, true) + modifier = Modifier.padding(8.dp).background(Color.Transparent).hoverable(interactionSource, true) ) } diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/search/Search.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/search/Search.kt index 7063f60..8e834c8 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/search/Search.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/search/Search.kt @@ -169,7 +169,7 @@ private fun SearchResult( if (currentVodList.value.isEmpty()) { Image( modifier = Modifier.align(Alignment.Center), - painter = painterResource("/icon/nothing.png"), + painter = painterResource("/pic/nothing.png"), contentDescription = "nothing here", contentScale = ContentScale.Crop ) diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/search/SearchBar.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/search/SearchBar.kt index a4ffe90..af61e40 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/search/SearchBar.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/search/SearchBar.kt @@ -60,7 +60,7 @@ fun SearchBar( var suggestions by remember { mutableStateOf(Suggest()) } - val searchFun = fun(text:String){ + val searchFun = fun(text: String) { onSearch(text) suggestions = Suggest() } @@ -93,9 +93,11 @@ fun SearchBar( shape = RoundedCornerShape(50), value = searchText, leadingIcon = { - AnimatedVisibility(visible = searching, + AnimatedVisibility( + visible = searching, enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut()){ + exit = fadeOut() + scaleOut() + ) { CircularProgressIndicator( modifier = Modifier.fillMaxHeight(), // color = MaterialTheme.colors.secondary, @@ -125,7 +127,7 @@ fun SearchBar( keyboardType = KeyboardType.Text, autoCorrect = true ), - colors = TextFieldDefaults.textFieldColors( + colors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, // 焦点时下划线颜色 unfocusedIndicatorColor = Color.Transparent, // backgroundColor = Color.Gray.copy(alpha = 0.3f), @@ -134,40 +136,38 @@ fun SearchBar( ) val scrollState = remember { ScrollState(0) } -// val showSuggestion by remember { mutableStateOf(false) } val showSuggestion = derivedStateOf { searchText.isNotEmpty() && !suggestions.isEmpty() } -// AnimatedVisibility(showSuggestion){ - DropdownMenu( - expanded = showSuggestion.value, - scrollState = scrollState, - offset = DpOffset(100.dp, 3.dp), - onDismissRequest = { - suggestions = Suggest() - }, - properties = PopupProperties( - focusable = false, - dismissOnBackPress = true, - dismissOnClickOutside = true, - clippingEnabled = true - ), - modifier = Modifier.animateContentSize(animationSpec = spring()) - .clip(RoundedCornerShape(15.dp)) - ) { - Column(Modifier.padding(horizontal = 15.dp, vertical = 5.dp)) { - suggestions.data?.forEach { - DropdownMenuItem( - modifier = Modifier, - onClick = { - searchFun(it.name) - }, - contentPadding = PaddingValues(horizontal = 15.dp, vertical = 5.dp) - ,text = {Text( + DropdownMenu( + expanded = showSuggestion.value, + scrollState = scrollState, + offset = DpOffset(100.dp, 3.dp), + onDismissRequest = { + suggestions = Suggest() + }, + properties = PopupProperties( + focusable = false, + dismissOnBackPress = true, + dismissOnClickOutside = true, + clippingEnabled = true + ), + modifier = Modifier.animateContentSize(animationSpec = spring()) + .clip(RoundedCornerShape(15.dp)) + ) { + Column(Modifier.padding(horizontal = 15.dp, vertical = 5.dp)) { + suggestions.data?.forEach { + DropdownMenuItem( + modifier = Modifier, + onClick = { + searchFun(it.name) + }, + contentPadding = PaddingValues(horizontal = 15.dp, vertical = 5.dp), text = { + Text( it.name, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.align(alignment = Alignment.CenterHorizontally) - )} - ) -// } + ) + } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/video/HorizontalItem.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/video/HorizontalItem.kt new file mode 100644 index 0000000..fcec814 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/video/HorizontalItem.kt @@ -0,0 +1,51 @@ +package com.corner.ui.video + +import AppTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.corner.catvod.enum.bean.Vod +import com.seiko.imageloader.ui.AutoSizeImage + +@Composable +fun HorizontalItem(modifier: Modifier, vod:Vod, onClick:(Vod)->Unit){ + Box(modifier.padding(8.dp) + .background(MaterialTheme.colorScheme.primaryContainer,RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) +// .border(1.dp, Color.Gray,RoundedCornerShape(8.dp)) + ){ + Row(Modifier.padding(8.dp).fillMaxSize()) { + AutoSizeImage(url = vod.vodPic ?: "", + modifier = Modifier/*.height(150.dp).width(130.dp)*/, + contentDescription = vod.vodName, + contentScale = ContentScale.Fit, + placeholderPainter = { painterResource("/pic/empty.png") }, + errorPainter = { painterResource("/pic/empty.png") }) + Spacer(Modifier.size(15.dp)) + Text(vod.vodName ?: "", modifier = Modifier + .align(Alignment.CenterVertically) + .fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onPrimaryContainer) + } + } +} + +@androidx.compose.desktop.ui.tooling.preview.Preview +@Composable +fun previewHorizonTaoItem(){ + AppTheme { + val v = Vod(vodName = "testedahdkasjfdkladjflkadfdsf") + HorizontalItem(Modifier.height(80.dp), v){} + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/video/QuickSearchItem.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/video/QuickSearchItem.kt index 943ee49..4c698ce 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/video/QuickSearchItem.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/video/QuickSearchItem.kt @@ -27,12 +27,12 @@ fun QuickSearchItem(vod:Vod, onClick:()->Unit){ Box(modifier = Modifier.background(MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp)) .clickable(onClick = {onClick()}) - .padding(horizontal = 15.dp, vertical = 10.dp)){ + .padding(horizontal = 10.dp, vertical = 6.dp)){ Column { ToolTipText(vod.vodName?:"", - TextStyle(fontWeight = FontWeight.Bold, fontSize = TextUnit(18f, TextUnitType.Sp), color = MaterialTheme.colorScheme.onPrimaryContainer)) - Text(vod.site?.name ?: "", fontSize = TextUnit(15f, TextUnitType.Sp),color = MaterialTheme.colorScheme.onSecondaryContainer) - ToolTipText(vod.vodRemarks ?: "", TextStyle(fontSize = TextUnit(15f, TextUnitType.Sp), color = MaterialTheme.colorScheme.onSecondaryContainer)) + TextStyle(fontWeight = FontWeight.Medium, fontSize = TextUnit(15f, TextUnitType.Sp), color = MaterialTheme.colorScheme.onPrimaryContainer)) + Text(vod.site?.name ?: "", fontSize = TextUnit(12f, TextUnitType.Sp),color = MaterialTheme.colorScheme.onSecondaryContainer) + ToolTipText(vod.vodRemarks ?: "", TextStyle(fontSize = TextUnit(12f, TextUnitType.Sp), color = MaterialTheme.colorScheme.onSecondaryContainer)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/corner/ui/video/Video.kt b/composeApp/src/commonMain/kotlin/com/corner/ui/video/Video.kt index bb331a7..9f0b668 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/ui/video/Video.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/ui/video/Video.kt @@ -1,13 +1,11 @@ package com.corner.ui.video -import AppTheme import SiteViewModel import androidx.compose.animation.* import androidx.compose.animation.core.animateDpAsState import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -18,7 +16,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.outlined.* -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -36,6 +33,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState @@ -64,20 +64,34 @@ fun VideoItem(modifier: Modifier, vod: Vod, showSite: Boolean, click: (Vod) -> U elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), shape = RoundedCornerShape(8.dp) ) { + val picModifier = remember { Modifier.height(220.dp).width(200.dp) } Box(modifier = modifier) { - AutoSizeImage(url = vod.vodPic ?: "", - modifier = Modifier.height(220.dp).width(200.dp), - contentDescription = vod.vodName, - contentScale = ContentScale.Crop, - placeholderPainter = { painterResource("/icon/empty.png") }, - errorPainter = { painterResource("/icon/empty.png") }) - Box(Modifier.align(Alignment.BottomCenter)){ + if(vod.isFolder()){ + Image( + modifier = modifier, + painter = painterResource("/pic/folder-back.png"), + contentDescription = "This is a folder ${vod.vodName}", + contentScale = ContentScale.Fit + ) + }else{ + AutoSizeImage(url = vod.vodPic ?: "", + modifier = picModifier, + contentDescription = vod.vodName, + contentScale = ContentScale.Crop, + placeholderPainter = { painterResource("/pic/empty.png") }, + errorPainter = { painterResource("/pic/empty.png") }) + } + Box(Modifier.align(Alignment.BottomCenter)) { ToolTipText( text = vod.vodName!!, - textStyle = TextStyle(color = MaterialTheme.colorScheme.onSecondaryContainer, textAlign = TextAlign.Center), + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSecondaryContainer, + textAlign = TextAlign.Center + ), modifier = Modifier .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)) - .fillMaxWidth().padding(0.dp, 10.dp)) + .fillMaxWidth().padding(0.dp, 10.dp) + ) } // 左上角 Text( @@ -107,6 +121,11 @@ fun VideoScene( val scope = rememberCoroutineScope() val state = rememberLazyGridState() val model = component.model.subscribeAsState() + val list = derivedStateOf { component.model.value.homeVodResult.toTypedArray() } + + LaunchedEffect(list.value){ + println("list 修改") + } LaunchedEffect(state) { snapshotFlow { state.layoutInfo } @@ -139,7 +158,8 @@ fun VideoScene( ) { Box(modifier = modifier.fillMaxSize().padding(it)) { Column { - if (model.value.classList.isNotEmpty()) { + val classIsEmpty = derivedStateOf { model.value.classList.isNotEmpty() } + if (classIsEmpty.value) { ClassRow(component) { component.model.update { it.copy(homeVodResult = SiteViewModel.result.value.list.toMutableSet()) } model.value.page.set(1) @@ -148,8 +168,9 @@ fun VideoScene( } } } - if (model.value.homeVodResult.isEmpty()) { - emptyShow() + val listEmpty = derivedStateOf { model.value.homeVodResult.isEmpty() } + if (listEmpty.value) { + emptyShow(onRefresh = { component.homeLoad() }) } else { Box { LazyVerticalGrid( @@ -161,12 +182,10 @@ fun VideoScene( horizontalArrangement = Arrangement.spacedBy(10.dp), // userScrollEnabled = true ) { - itemsIndexed(model.value.homeVodResult.toList()) { _, item -> + itemsIndexed(list.value, key = { i, item -> item.vodId + item.vodName+i }) { _, item -> VideoItem(Modifier.animateItemPlacement(), item, false) { if (item.isFolder()) { - SiteViewModel.viewModelScope.launch { - - } + component.clickFolder(it) } else { onClickItem(it) } @@ -188,7 +207,7 @@ fun VideoScene( } @Composable -fun FloatButton(component: DefaultVideoComponent, state: LazyGridState, scope:CoroutineScope) { +fun FloatButton(component: DefaultVideoComponent, state: LazyGridState, scope: CoroutineScope) { val show = derivedStateOf { GlobalModel.chooseVod.value.isFolder() } val model = component.model.subscribeAsState() val showButton = derivedStateOf { !model.value.currentFilter.isEmpty() || state.firstVisibleItemIndex > 8 } @@ -251,12 +270,13 @@ fun FloatButton(component: DefaultVideoComponent, state: LazyGridState, scope:Co contentColor = MaterialTheme.colorScheme.onPrimaryContainer ) if (it) { - if(model.value.currentFilter.isEmpty()) return@AnimatedContent + if (model.value.currentFilter.isEmpty()) return@AnimatedContent ElevatedButton( onClick = { showDialog = !showDialog }, modifier = modifier, colors = buttonColors, - shape = shape, contentPadding = PaddingValues(8.dp) + shape = shape, + contentPadding = PaddingValues(8.dp) ) { Icon( @@ -299,62 +319,68 @@ fun VideoTopBar( TopAppBar(modifier = Modifier.height(50.dp).padding(1.dp), title = {}, actions = { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) { - IconButton(modifier = Modifier.size(120.dp) - .indication( - MutableInteractionSource(), - indication = rememberRipple(bounded = true, radius = 50.dp) - ), - onClick = { onClickChooseHome() }) { - Row(Modifier.wrapContentWidth()) { - Icon( - Icons.Outlined.ArrowDropDown, - contentDescription = "Choose Home", - modifier = Modifier.padding(end = 3.dp) - ) - Text(home.value.name, modifier = Modifier.wrapContentWidth()) + Row(Modifier.fillMaxWidth(),horizontalArrangement = Arrangement.SpaceBetween) { + ElevatedButton(modifier = Modifier.align(Alignment.Top).wrapContentWidth().padding(start = 5.dp), + onClick = { onClickChooseHome() }, + colors = ButtonDefaults.elevatedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background, disabledContentColor = MaterialTheme.colorScheme.background), + elevation = ButtonDefaults.buttonElevation(), + ){ + Row(Modifier.wrapContentWidth()) { + Icon( + Icons.Outlined.ArrowDropDown, + contentDescription = "Choose Home", + modifier = Modifier.padding(end = 3.dp) + ) + Text( + home.value.name, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + fontSize = TextUnit(15f, TextUnitType.Sp) + ) + } } - } - Box(modifier = Modifier.align(Alignment.Center) - .fillMaxWidth(0.3f) - .fillMaxHeight(0.6f) - .background(Color.Gray.copy(alpha = 0.3f), shape = RoundedCornerShape(percent = 50)) - .clickable { - onClickSearch() - }) { - AnimatedContent( - targetState = model.value.prompt, - contentAlignment = Alignment.Center, - transitionSpec = { - slideInVertically { height -> height } + fadeIn() togetherWith - slideOutVertically { height -> -height } + fadeOut() - }, - modifier = Modifier.fillMaxHeight()/*.padding(top = 4.dp)*/ - ) { - Text( - text = it, - modifier = Modifier.align(Alignment.Center) - .fillMaxWidth() - .fillMaxHeight(), - textAlign = TextAlign.Center + Box(modifier = Modifier.align(Alignment.CenterVertically) + .fillMaxWidth(0.3f) + .fillMaxHeight(0.6f) + .background(Color.Gray.copy(alpha = 0.3f), shape = RoundedCornerShape(percent = 50)) + .clickable { + onClickSearch() + }) { + AnimatedContent( + targetState = model.value.prompt, + contentAlignment = Alignment.Center, + transitionSpec = { + slideInVertically { height -> height } + fadeIn() togetherWith + slideOutVertically { height -> -height } + fadeOut() + }, + modifier = Modifier.fillMaxHeight()/*.padding(top = 4.dp)*/ + ) { + Text( + text = it, + modifier = Modifier.align(Alignment.Center) + .fillMaxWidth() + .fillMaxHeight(), + textAlign = TextAlign.Center + ) + } + Icon( + Icons.Outlined.Search, + contentDescription = "搜索", + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 15.dp) ) } - Icon( - Icons.Outlined.Search, - contentDescription = "搜索", - modifier = Modifier.align(Alignment.CenterEnd).padding(end = 15.dp) - ) - } - Row(modifier = Modifier.align(Alignment.CenterEnd)) { - IconButton(onClick = { - onClickHistory() - }, modifier = Modifier.padding(end = 20.dp)) { - Icon(Icons.Outlined.History, "history") - } - IconButton(onClick = { - onClickSetting() - }, modifier = Modifier.padding(end = 20.dp)) { - Icon(Icons.Outlined.Settings, "settings") + Row(modifier = Modifier.align(Alignment.Bottom)) { + IconButton(onClick = { + onClickHistory() + }, modifier = Modifier.padding(end = 20.dp)) { + Icon(Icons.Outlined.History, "history") + } + IconButton(onClick = { + onClickSetting() + }, modifier = Modifier) { + Icon(Icons.Outlined.Settings, "settings") + } } } } @@ -395,48 +421,48 @@ fun ClassRow(component: DefaultVideoComponent, onCLick: (Type) -> Unit) { contentPadding = PaddingValues(top = 5.dp, start = 5.dp, end = 5.dp, bottom = 5.dp), userScrollEnabled = true ) { - items(model.value.classList.toList()) { type -> + val list = derivedStateOf { model.value.classList.toList() } + items(list.value) { type -> RatioBtn(text = type.typeName, onClick = { if (component.isLoading.get()) return@RatioBtn component.isLoading.set(true) SiteViewModel.viewModelScope.launch { showProgress() -// component.clear() - try { - for (tp in model.value.classList) { - tp.selected = type.typeId == tp.typeId - } - model.value.currentClass = type - model.value.classList = model.value.classList.toSet().toMutableSet() - val filterMap = SiteViewModel.result.value.filters - if (filterMap.isNotEmpty()) { - component.model.value.filtersMap = filterMap - component.model.update { - it.copy( - currentFilter = component.getFilters(type) - ) - } - } - if (type.typeId == "home") { - SiteViewModel.homeContent() - } else { - val result = SiteViewModel.categoryContent( - GlobalModel.home.value.key, - type.typeId, - model.value.page.getAndAdd(1).toString(), - false, - HashMap() + for (tp in model.value.classList) { + tp.selected = type.typeId == tp.typeId + } + if (model.value.filtersMap.isNotEmpty()) { + component.model.update { + it.copy( + currentFilter = component.getFilters(type) ) - if (!result.isSuccess) { - model.value.currentClass?.failTime?.plus(1) - } } - } finally { - hideProgress() + } + component.model.update { + it.copy( + currentClass = type, classList = model.value.classList + ) + } + SiteViewModel.cancelAll() + if (type.typeId == "home") { + SiteViewModel.homeContent() + } else { + model.value.page.set(1) + val result = SiteViewModel.categoryContent( + GlobalModel.home.value.key, + type.typeId, + model.value.page.get().toString(), + false, + HashMap() + ) + if (!result.isSuccess) { + model.value.currentClass?.failTime?.plus(1) + } } }.invokeOnCompletion { onCLick(type) component.isLoading.set(false) + hideProgress() } }, type.selected) } @@ -453,10 +479,10 @@ fun ClassRow(component: DefaultVideoComponent, onCLick: (Type) -> Unit) { @Composable @Preview fun previewClassRow() { - AppTheme { - val list = listOf(Type("1", "ABC"), Type("2", "CDR"), Type("3", "ddr")) -// ClassRow(list.toMutableSet()) {} - } +// AppTheme { +// val list = listOf(Type("1", "ABC"), Type("2", "CDR"), Type("3", "ddr")) +//// ClassRow(list.toMutableSet()) {} +// } } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/corner/util/Catch.kt b/composeApp/src/commonMain/kotlin/com/corner/util/Catch.kt new file mode 100644 index 0000000..3d5fb3e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/util/Catch.kt @@ -0,0 +1,3 @@ +package com.corner.util + +fun catch(body: () -> Unit) = runCatching { body() }.onFailure { it.printStackTrace() }.getOrNull() ?: Unit \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/util/Constants.kt b/composeApp/src/commonMain/kotlin/com/corner/util/Constants.kt index 32a073f..852b400 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/util/Constants.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/util/Constants.kt @@ -3,6 +3,10 @@ package com.corner.util import androidx.compose.ui.graphics.Color object Constants { + val EpSize: Int = 15 + val lightBlue = Color(94, 181, 247) val darkBlue = Color(14, 22, 33) + + val resPathKey = "compose.application.resources.dir" } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/util/SSLSocketClient.java b/composeApp/src/commonMain/kotlin/com/corner/util/SSLSocketClient.java new file mode 100644 index 0000000..89519cf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/corner/util/SSLSocketClient.java @@ -0,0 +1,12 @@ +package com.corner.util; + +import javax.net.ssl.*; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +public class SSLSocketClient { + + +} diff --git a/composeApp/src/commonMain/kotlin/com/corner/util/extensions.kt b/composeApp/src/commonMain/kotlin/com/corner/util/extensions.kt index 60e1f09..b93926b 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/util/extensions.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/util/extensions.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.* import com.corner.catvod.enum.bean.Site import io.ktor.util.* import kotlinx.coroutines.Job +import java.util.concurrent.TimeUnit fun Site.isEmpty():Boolean{ return key.isEmpty() || name.isEmpty() @@ -42,4 +43,11 @@ fun LazyGridState.isScrollingUp(): Boolean { } } }.value +} + +fun Long.formatTimestamp(): String { + val hours = TimeUnit.MILLISECONDS.toHours(this) + val minutes = TimeUnit.MILLISECONDS.toMinutes(this) - TimeUnit.HOURS.toMinutes(hours) + val seconds = TimeUnit.MILLISECONDS.toSeconds(this) - TimeUnit.MINUTES.toSeconds(minutes) - TimeUnit.HOURS.toSeconds(hours) + return String.format("%02d:%02d:%02d", hours, minutes, seconds) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/util/play/MPC.kt b/composeApp/src/commonMain/kotlin/com/corner/util/play/MPC.kt index 66885af..a19c25b 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/util/play/MPC.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/util/play/MPC.kt @@ -1,5 +1,6 @@ import com.corner.catvodcore.bean.Result import com.corner.catvodcore.bean.v +import com.corner.catvodcore.util.Paths import com.corner.util.play.PlayerCommand object MPC: PlayerCommand { @@ -17,6 +18,10 @@ object MPC: PlayerCommand { } override fun getProcessBuilder(result: Result, title: String, playerPath: String): ProcessBuilder { - return ProcessBuilder(playerPath, url(result.url.v()), "/play") + return ProcessBuilder(playerPath, url(result.url.v()), "/play").redirectOutput(Paths.playerLog()) + } + + override fun getProcessBuilder(url: String, title: String, playerPath: String): ProcessBuilder { + return ProcessBuilder(playerPath, url(url), "/play").redirectOutput(Paths.playerLog()) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/util/play/Play.kt b/composeApp/src/commonMain/kotlin/com/corner/util/play/Play.kt index 7fa5204..3ffde43 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/util/play/Play.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/util/play/Play.kt @@ -4,9 +4,12 @@ import MPC import PotPlayer import cn.hutool.core.util.ZipUtil import com.corner.bean.SettingStore -import com.corner.bean.SettingType import com.corner.catvodcore.bean.Result import com.corner.catvodcore.bean.v +import com.corner.catvodcore.util.Paths +import com.corner.ui.getPlayerSetting +import com.corner.ui.scene.SnackBar +import com.corner.util.Constants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -27,6 +30,11 @@ class Play { getProcessBuilder(result, title)?.start() } } + fun start(url: String, title: String?){ + CoroutineScope(Dispatchers.IO).launch{ + getProcessBuilder(url, title)?.start() + } + } } } @@ -37,23 +45,15 @@ class Play { */ fun getProcessBuilder(result: Result?, title: String?): ProcessBuilder? { if (result == null) return null - val playerPath = SettingStore.getSettingItem(SettingType.PLAYER.id) + val playerPath = SettingStore.getPlayerSetting()[1] as String if(SystemUtils.IS_OS_MAC){ return if(checkPlayer(playerPath)){ - ProcessBuilder("open", "-a", playerPath, result.url?.v() ?: "") + ProcessBuilder("open", "-a", playerPath, result.url.v()).redirectOutput(Paths.playerLog()) }else{ - ProcessBuilder("open", result.url?.v() ?: "") - } - } - if(!checkPlayer(playerPath)) { - log.info("未配置播放器 使用默认播放器") - val path = getDefaultPlayerPath() - log.info("默认播放器路径:{}", path) - if(path.isBlank()){ - return null + ProcessBuilder("open", result.url.v()).redirectOutput(Paths.playerLog()) } - return MPC.getProcessBuilder(result, title ?: "TV", path) } +// i val compare = File(playerPath).name.lowercase(Locale.getDefault()) if(compare.contains("potplayer")){ return PotPlayer.getProcessBuilder(result,title ?: "TV", playerPath) @@ -66,6 +66,33 @@ fun getProcessBuilder(result: Result?, title: String?): ProcessBuilder? { return Default.getProcessBuilder(result, title ?: "TV", playerPath) } +fun getProcessBuilder(url:String, title: String?): ProcessBuilder? { + if (StringUtils.isBlank(url)) return null + val playerPath = SettingStore.getPlayerSetting()[1] as String + if(StringUtils.isBlank(playerPath)) { + SnackBar.postMsg("未配置外部播放器路径") + return null + } + if(SystemUtils.IS_OS_MAC){ + return if(checkPlayer(playerPath)){ + ProcessBuilder("open", "-a", playerPath, url) + }else{ + ProcessBuilder("open", url) + } + } +// i + val compare = File(playerPath).name.lowercase(Locale.getDefault()) + if(compare.contains("potplayer")){ + return PotPlayer.getProcessBuilder(url,title ?: "TV", playerPath) + }else if(compare.contains("vlc")){ + return VLC.getProcessBuilder(url, title ?: "TV", playerPath) + } + else if(compare.contains("mpc-be")){ + return MPC.getProcessBuilder(url, title ?: "TV", playerPath) + } + return Default.getProcessBuilder(url, title ?: "TV", playerPath) +} + fun getDefaultPlayerPath():String { val resourcesDir = File(System.getProperty("compose.application.resources.dir")) // 已经解压 @@ -89,6 +116,32 @@ fun getDefaultPlayerPath():String { return destDir.resolve(exeList[0]).path } +/** + * @param name dest dir name + * @param exePattern 匹配exe可执行文件的regx "mpc-hc\\X*.exe" + */ +fun findAndExtract(dirName:String, exePattern:String): String? { + val resourcesDir = File(System.getProperty(Constants.resPathKey)) + var exeList = resourcesDir.resolve(dirName).list(FilenameFilter { _, name -> name.lowercase().matches(Regex(exePattern)) }) + if(exeList != null && exeList.isNotEmpty()) return resourcesDir.resolve(dirName).resolve(exeList[0]).path + + val list = resourcesDir.list(FilenameFilter { _, name -> name.lowercase().matches(Regex(exePattern)) }) + if(list == null || list.isEmpty()) { + log.error("没有找到压缩包") + return "" + } + val destDir = resourcesDir.resolve(dirName) + log.info("解压压缩包 $list") + + ZipUtil.unzip(resourcesDir.resolve(list[0]), destDir.path.toPath().toFile()) + exeList = destDir.list(FilenameFilter { _, name -> name.lowercase().matches(Regex(exePattern)) }) + if(exeList == null || exeList.isEmpty()) { + log.error("没有找到播放器exe") + return "" + } + return exeList.first() + } + private fun checkPlayer(playerPath:String):Boolean{ // if(StringUtils.isBlank(playerPath)){ // SnackBar.postMsg("请配置播放器路径") diff --git a/composeApp/src/commonMain/kotlin/com/corner/util/play/PlayerCommand.kt b/composeApp/src/commonMain/kotlin/com/corner/util/play/PlayerCommand.kt index 9f58f13..caeaff1 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/util/play/PlayerCommand.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/util/play/PlayerCommand.kt @@ -1,7 +1,10 @@ package com.corner.util.play +import androidx.compose.ui.graphics.Path import com.corner.catvodcore.bean.Result import com.corner.catvodcore.bean.v +import com.corner.catvodcore.util.Paths +import org.apache.commons.lang3.SystemUtils interface PlayerCommand{ // fun currentProcess(): String @@ -17,11 +20,15 @@ interface PlayerCommand{ // fun add(s:String):String fun url(s:String):String{ - return "\"$s\"" + return if(SystemUtils.IS_OS_WINDOWS) "\"$s\"" else s } fun getProcessBuilder(result: Result, title: String, playerPath: String):ProcessBuilder{ - return ProcessBuilder(playerPath, url(result.url.v())) + return ProcessBuilder(playerPath, result.url.v()).redirectOutput(Paths.playerLog()) + } + + fun getProcessBuilder(url: String, title: String, playerPath: String):ProcessBuilder{ + return ProcessBuilder(playerPath, url(url)).redirectOutput(Paths.playerLog()) } fun buildHeaderStr(headers:Map?):String{ diff --git a/composeApp/src/commonMain/kotlin/com/corner/util/play/Potplayer.kt b/composeApp/src/commonMain/kotlin/com/corner/util/play/Potplayer.kt index e91dd88..ef2a9c9 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/util/play/Potplayer.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/util/play/Potplayer.kt @@ -44,6 +44,16 @@ object PotPlayer: PlayerCommand { headers(result.header), title(title) ) - return processBuilder.redirectOutput(Paths.log()) + return processBuilder.redirectOutput(Paths.playerLog()) + } + + override fun getProcessBuilder(url: String, title: String, playerPath: String): ProcessBuilder { + val processBuilder = ProcessBuilder( + playerPath, + url(url), + currentProcess(), + title(title) + ) + return processBuilder.redirectOutput(Paths.playerLog()) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/corner/util/play/VLC.kt b/composeApp/src/commonMain/kotlin/com/corner/util/play/VLC.kt index efbc9b5..48d7aa7 100644 --- a/composeApp/src/commonMain/kotlin/com/corner/util/play/VLC.kt +++ b/composeApp/src/commonMain/kotlin/com/corner/util/play/VLC.kt @@ -2,6 +2,7 @@ package com.corner.util.play import com.corner.catvodcore.bean.Result import com.corner.catvodcore.bean.v +import com.corner.catvodcore.util.Paths object VLC: PlayerCommand { override fun title(title: String): String { @@ -17,6 +18,10 @@ object VLC: PlayerCommand { } override fun getProcessBuilder(result: Result, title: String, playerPath: String): ProcessBuilder { - return ProcessBuilder(playerPath, title(title), /*"--playlist-tree",*/ url(result.url.v())) + return ProcessBuilder(playerPath, title(title), /*"--playlist-tree",*/ result.url.v()).redirectOutput(Paths.playerLog()) + } + + override fun getProcessBuilder(url: String, title: String, playerPath: String): ProcessBuilder { + return ProcessBuilder(playerPath, title(title), /*"--playlist-tree",*/ url).redirectOutput(Paths.playerLog()) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/icon/TV-icon-s.png b/composeApp/src/commonMain/resources/pic/TV-icon-s.png similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/TV-icon-s.png rename to composeApp/src/commonMain/resources/pic/TV-icon-s.png diff --git a/composeApp/src/commonMain/composeResources/icon/TV-icon-x.png b/composeApp/src/commonMain/resources/pic/TV-icon-x.png similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/TV-icon-x.png rename to composeApp/src/commonMain/resources/pic/TV-icon-x.png diff --git a/composeApp/src/commonMain/composeResources/icon/TV-icon-xs.png b/composeApp/src/commonMain/resources/pic/TV-icon-xs.png similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/TV-icon-xs.png rename to composeApp/src/commonMain/resources/pic/TV-icon-xs.png diff --git a/composeApp/src/commonMain/composeResources/icon/TV-icon_1_s.png b/composeApp/src/commonMain/resources/pic/TV-icon_1_s.png similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/TV-icon_1_s.png rename to composeApp/src/commonMain/resources/pic/TV-icon_1_s.png diff --git a/composeApp/src/commonMain/composeResources/icon/avatar.png b/composeApp/src/commonMain/resources/pic/avatar.png similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/avatar.png rename to composeApp/src/commonMain/resources/pic/avatar.png diff --git a/composeApp/src/commonMain/composeResources/icon/empty.png b/composeApp/src/commonMain/resources/pic/empty.png similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/empty.png rename to composeApp/src/commonMain/resources/pic/empty.png diff --git a/composeApp/src/commonMain/composeResources/icon/enter-fullscreen.svg b/composeApp/src/commonMain/resources/pic/enter-fullscreen.svg similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/enter-fullscreen.svg rename to composeApp/src/commonMain/resources/pic/enter-fullscreen.svg diff --git a/composeApp/src/commonMain/composeResources/icon/exit-fullscreen.svg b/composeApp/src/commonMain/resources/pic/exit-fullscreen.svg similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/exit-fullscreen.svg rename to composeApp/src/commonMain/resources/pic/exit-fullscreen.svg diff --git a/composeApp/src/commonMain/resources/pic/folder-back.png b/composeApp/src/commonMain/resources/pic/folder-back.png new file mode 100644 index 0000000..31b892b Binary files /dev/null and b/composeApp/src/commonMain/resources/pic/folder-back.png differ diff --git a/composeApp/src/commonMain/composeResources/icon/icon-s.ico b/composeApp/src/commonMain/resources/pic/icon-s.ico similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/icon-s.ico rename to composeApp/src/commonMain/resources/pic/icon-s.ico diff --git a/composeApp/src/commonMain/composeResources/icon/icon.icns b/composeApp/src/commonMain/resources/pic/icon.icns similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/icon.icns rename to composeApp/src/commonMain/resources/pic/icon.icns diff --git a/composeApp/src/commonMain/composeResources/icon/icon.svg b/composeApp/src/commonMain/resources/pic/icon.svg similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/icon.svg rename to composeApp/src/commonMain/resources/pic/icon.svg diff --git a/composeApp/src/commonMain/composeResources/icon/nothing.png b/composeApp/src/commonMain/resources/pic/nothing.png similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/nothing.png rename to composeApp/src/commonMain/resources/pic/nothing.png diff --git a/composeApp/src/commonMain/composeResources/icon/pause.svg b/composeApp/src/commonMain/resources/pic/pause.svg similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/pause.svg rename to composeApp/src/commonMain/resources/pic/pause.svg diff --git a/composeApp/src/commonMain/composeResources/icon/play.svg b/composeApp/src/commonMain/resources/pic/play.svg similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/play.svg rename to composeApp/src/commonMain/resources/pic/play.svg diff --git a/composeApp/src/commonMain/composeResources/icon/speed.svg b/composeApp/src/commonMain/resources/pic/speed.svg similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/speed.svg rename to composeApp/src/commonMain/resources/pic/speed.svg diff --git a/composeApp/src/commonMain/composeResources/icon/volume.svg b/composeApp/src/commonMain/resources/pic/volume.svg similarity index 100% rename from composeApp/src/commonMain/composeResources/icon/volume.svg rename to composeApp/src/commonMain/resources/pic/volume.svg diff --git a/composeApp/src/commonMain/sqldelight/com/corner/database/Config.sq b/composeApp/src/commonMain/sqldelight/com/corner/database/Config.sq index b446507..b7477d6 100644 --- a/composeApp/src/commonMain/sqldelight/com/corner/database/Config.sq +++ b/composeApp/src/commonMain/sqldelight/com/corner/database/Config.sq @@ -39,5 +39,14 @@ SELECT * FROM Config WHERE type = ? AND url = ?; +getAll: +SELECT * FROM +Config +ORDER BY time DESC; + +deleteById: +DELETE FROM Config WHERE id = ?; + + diff --git a/composeApp/src/commonMain/sqldelight/com/corner/database/History.sq b/composeApp/src/commonMain/sqldelight/com/corner/database/History.sq index 972dc42..fbff2c7 100644 --- a/composeApp/src/commonMain/sqldelight/com/corner/database/History.sq +++ b/composeApp/src/commonMain/sqldelight/com/corner/database/History.sq @@ -20,7 +20,7 @@ WHERE key = ?; updateSome: -UPDATE History SET vodFlag = ?, vodRemarks = ?, episodeUrl = ? +UPDATE History SET vodFlag = ?, vodRemarks = ?, episodeUrl = ?,position = ?,speed = ?,opening = ?, ending = ?, createTime = CURRENT_TIMESTAMP WHERE key = ?; getAll: @@ -33,4 +33,8 @@ WHERE key IN ?; deleteAll: DELETE FROM History -WHERE cid = ?; \ No newline at end of file +WHERE cid = ?; + +updateOpeningEnding: +UPDATE History SET opening = ?, ending = ? +WHERE key = ?; \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/com/corner/database/Keep.sq b/composeApp/src/commonMain/sqldelight/com/corner/database/Keep.sq new file mode 100644 index 0000000..da93152 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/com/corner/database/Keep.sq @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `Keep` (`key` TEXT NOT NULL, `siteName` TEXT, `vodName` TEXT, `vodPic` TEXT, `createTime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`)); + +getAll: +SELECT * FROM Keep +WHERE cid = ? ORDER BY createTime DESC; + + +save: +INSERT INTO Keep(key, siteName, vodName, vodPic, createTime, type, cid) +VALUES(?,?,?,?,CURRENT_TIMESTAMP ,?,?); \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/corner/RootContent.kt b/composeApp/src/desktopMain/kotlin/com/corner/RootContent.kt index 24d43ec..edd1fcc 100644 --- a/composeApp/src/desktopMain/kotlin/com/corner/RootContent.kt +++ b/composeApp/src/desktopMain/kotlin/com/corner/RootContent.kt @@ -7,8 +7,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf 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.window.WindowPlacement @@ -17,11 +17,10 @@ import androidx.compose.ui.window.WindowState import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.fade import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import com.corner.catvodcore.enum.Menu -import com.corner.ui.ControlBar -import com.corner.ui.DetailScene -import com.corner.ui.HistoryScene -import com.corner.ui.SettingScene +import com.corner.catvodcore.viewmodel.GlobalModel +import com.corner.ui.* import com.corner.ui.scene.LoadingIndicator import com.corner.ui.scene.SnackBar import com.corner.ui.scene.isShowProgress @@ -31,22 +30,21 @@ import com.corner.ui.video.VideoScene @Composable fun WindowScope.RootContent(component: RootComponent, modifier: Modifier = Modifier, state:WindowState, onClose:()->Unit) { + val isFullScreen = GlobalModel.videoFullScreen.subscribeAsState() + val borderStroke = derivedStateOf { if(isFullScreen.value) BorderStroke(0.dp, Color.Transparent) else BorderStroke(1.dp, Color(59, 59, 60)) } +// val isDebug = derivedStateOf { System.getProperty("org.gradle.project.buildType") == "debug" } AppTheme(useDarkTheme = true) { Column( - modifier = Modifier.fillMaxSize()/*.clip(RoundedCornerShape(12.dp))*/.border( - border = BorderStroke(1.dp, Color(59, 59, 60)), // firefox的边框灰色 - ).shadow( - 5.dp, - ambientColor = Color.DarkGray, spotColor = Color.DarkGray - ) - ) { - WindowDraggableArea { - ControlBar(onClickMinimize = { state.isMinimized = !state.isMinimized }, - onClickMaximize = { - state.placement = - if (WindowPlacement.Maximized == state.placement) WindowPlacement.Floating else WindowPlacement.Maximized - }, - onClickClose = { onClose() }) + modifier = Modifier.fillMaxSize().border(border = borderStroke.value)) { + if(!isFullScreen.value){ + WindowDraggableArea { + ControlBar(onClickMinimize = { state.isMinimized = !state.isMinimized }, + onClickMaximize = { + state.placement = + if (WindowPlacement.Maximized == state.placement) WindowPlacement.Floating else WindowPlacement.Maximized + }, + onClickClose = { onClose() }) + } } Children(stack = component.childStack, modifier = modifier, animation = stackAnimation(fade())){ when (val child = it.instance) { @@ -68,5 +66,8 @@ fun WindowScope.RootContent(component: RootComponent, modifier: Modifier = Modif LoadingIndicator(showProgress = isShowProgress()) } } +// if(isDebug.value){ +// FpsMonitor(Modifier) +// } } } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/corner/init/CustomDirectoryDiscovery.kt b/composeApp/src/desktopMain/kotlin/com/corner/init/CustomDirectoryDiscovery.kt new file mode 100644 index 0000000..479b016 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/corner/init/CustomDirectoryDiscovery.kt @@ -0,0 +1,39 @@ +package com.corner.init + +import com.corner.bean.SettingStore +import com.corner.bean.SettingType +import com.corner.util.Constants +import org.apache.commons.lang3.StringUtils +import uk.co.caprica.vlcj.factory.discovery.provider.DiscoveryDirectoryProvider +import java.io.File + + +class CustomDirectoryDiscovery:DiscoveryDirectoryProvider { + private val resPath = System.getProperty(Constants.resPathKey) ?: "" + + private var vlcPath:String? = null + + init { + if(resPath.isNotBlank()){ + vlcPath = File(resPath).resolve("vlc").path + } + } + + override fun priority(): Int { + return 50 + } + + override fun directories(): Array { + val arrayOf = mutableListOf(vlcPath ?: "") + val playerPath = SettingStore.getSettingItem(SettingType.PLAYER.id).split("#") + if(playerPath.size > 1 && StringUtils.isNotBlank(playerPath[1])){ + if(!File(playerPath[1]).exists()) return arrayOf.toTypedArray() + arrayOf.add(File(playerPath[1]).parent) + } + return arrayOf.toTypedArray() + } + + override fun supported(): Boolean { + return true + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/corner/init/Init.desktop.kt b/composeApp/src/desktopMain/kotlin/com/corner/init/Init.desktop.kt index 92c8d66..583e15d 100644 --- a/composeApp/src/desktopMain/kotlin/com/corner/init/Init.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/corner/init/Init.desktop.kt @@ -3,7 +3,9 @@ package com.corner.init import com.corner.catvodcore.util.KtorHeaderUrlFetcher import com.corner.catvodcore.util.Paths import com.seiko.imageloader.ImageLoader -import com.seiko.imageloader.defaultImageResultMemoryCache +import com.seiko.imageloader.intercept.bitmapMemoryCacheConfig +import com.seiko.imageloader.intercept.imageMemoryCacheConfig +import com.seiko.imageloader.intercept.painterMemoryCacheConfig import okio.Path.Companion.toOkioPath actual fun initPlatformSpecify() { @@ -16,10 +18,17 @@ fun generateImageLoader(): ImageLoader { add(KtorHeaderUrlFetcher.CustomUrlFetcher) } interceptor { - // cache 100 success image result, without bitmap - defaultImageResultMemoryCache() - memoryCacheConfig { - maxSizeBytes(32 * 1024 * 1024) // 32MB + // cache 32MB bitmap + bitmapMemoryCacheConfig { + maxSize(32 * 1024 * 1024) // 32MB + } + // cache 50 image + imageMemoryCacheConfig { + maxSize(50) + } + // cache 50 painter + painterMemoryCacheConfig { + maxSize(50) } diskCacheConfig { diff --git a/composeApp/src/desktopMain/kotlin/com/corner/ui/DesktopVideoPlayer.kt b/composeApp/src/desktopMain/kotlin/com/corner/ui/DesktopVideoPlayer.kt deleted file mode 100644 index 39af8e8..0000000 --- a/composeApp/src/desktopMain/kotlin/com/corner/ui/DesktopVideoPlayer.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.corner.ui - -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.SwingPanel -import androidx.compose.ui.graphics.Color -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery -import uk.co.caprica.vlcj.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter -import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent -import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent -import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer -import java.awt.Component -import java.util.* -import kotlin.math.roundToInt - -@Composable -internal actual fun VideoPlayerImpl( - url: String, - isResumed: Boolean, - volume: Float, - speed: Float, - seek: Float, - isFullscreen: Boolean, - progressState: MutableState, - modifier: Modifier, - onFinish: (() -> Unit)? -) { - val mediaPlayerComponent = remember { initializeMediaPlayerComponent() } - val mediaPlayer = remember { mediaPlayerComponent.mediaPlayer() } - mediaPlayer.emitProgressTo(progressState) - mediaPlayer.setupVideoFinishHandler(onFinish) - - val factory = remember { { mediaPlayerComponent } } - /* OR the following code and using SwingPanel(factory = { factory }, ...) */ - // val factory by rememberUpdatedState(mediaPlayerComponent) - - LaunchedEffect(url) { mediaPlayer.media().play/*OR .start*/(url) } - LaunchedEffect(seek) { mediaPlayer.controls().setPosition(seek) } - LaunchedEffect(speed) { mediaPlayer.controls().setRate(speed) } - LaunchedEffect(volume) { mediaPlayer.audio().setVolume(volume.toPercentage()) } - LaunchedEffect(isResumed) { mediaPlayer.controls().setPause(!isResumed) } - LaunchedEffect(isFullscreen) { - if (mediaPlayer is EmbeddedMediaPlayer) { - /* - * To be able to access window in the commented code below, - * extend the player composable function from WindowScope. - * See https://github.com/JetBrains/compose-jb/issues/176#issuecomment-812514936 - * and its subsequent comments. - * - * We could also just fullscreen the whole window: - * `window.placement = WindowPlacement.Fullscreen` - * See https://github.com/JetBrains/compose-multiplatform/issues/1489 - */ - // mediaPlayer.fullScreen().strategy(ExclusiveModeFullScreenStrategy(window)) - mediaPlayer.fullScreen().toggle() - } - } - DisposableEffect(Unit) { onDispose(mediaPlayer::release) } - SwingPanel( - factory = factory, - background = Color.Transparent, - modifier = modifier - ) -} - -private fun Float.toPercentage(): Int = (this * 100).roundToInt() - -/** - * See https://github.com/caprica/vlcj/issues/887#issuecomment-503288294 - * for why we're using CallbackMediaPlayerComponent for macOS. - */ -private fun initializeMediaPlayerComponent(): Component { - NativeDiscovery().discover() - return if (isMacOS()) { - CallbackMediaPlayerComponent() - } else { - EmbeddedMediaPlayerComponent() - } -} - -/** - * We play the video again on finish (so the player is kind of idempotent), - * unless the [onFinish] callback stops the playback. - * Using `mediaPlayer.controls().repeat = true` did not work as expected. - */ -@Composable -private fun MediaPlayer.setupVideoFinishHandler(onFinish: (() -> Unit)?) { - DisposableEffect(onFinish) { - val listener = object : MediaPlayerEventAdapter() { - override fun stopped(mediaPlayer: MediaPlayer) { - onFinish?.invoke() - mediaPlayer.controls().play() - } - } - events().addMediaPlayerEventListener(listener) - onDispose { events().removeMediaPlayerEventListener(listener) } - } -} - -/** - * Checks for and emits video progress every 50 milliseconds. - * Note that it seems vlcj updates the progress only every 250 milliseconds or so. - * - * Instead of using `Unit` as the `key1` for [LaunchedEffect], - * we could use `media().info()?.mrl()` if it's needed to re-launch - * the effect (for whatever reason) when the url (aka video) changes. - */ -@Composable -private fun MediaPlayer.emitProgressTo(state: MutableState) { - LaunchedEffect(key1 = Unit) { - while (isActive) { - val fraction = status().position() - val time = status().time() - state.value = Progress(fraction, time) - delay(50) - } - } -} - -/** - * Returns [MediaPlayer] from player components. - * The method names are the same, but they don't share the same parent/interface. - * That's why we need this method. - */ -private fun Component.mediaPlayer() = when (this) { - is CallbackMediaPlayerComponent -> mediaPlayer() - is EmbeddedMediaPlayerComponent -> mediaPlayer() - else -> error("mediaPlayer() can only be called on vlcj player components") -} - -private fun isMacOS(): Boolean { - val os = System - .getProperty("os.name", "generic") - .lowercase(Locale.ENGLISH) - return "mac" in os || "darwin" in os -} diff --git a/composeApp/src/desktopMain/kotlin/com/corner/ui/FpsMonitor.kt b/composeApp/src/desktopMain/kotlin/com/corner/ui/FpsMonitor.kt new file mode 100644 index 0000000..9936cb1 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/corner/ui/FpsMonitor.kt @@ -0,0 +1,42 @@ +package com.corner.ui + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import cn.hutool.system.SystemUtil + +/** + * from acfun-multiplatform-client + */ + +@Composable +fun FpsMonitor(modifier: Modifier) { + var fpsCount by remember { mutableStateOf(0) } + var fps by remember { mutableStateOf(0) } + var lastUpdate by remember { mutableStateOf(0L) } + val platformName = remember { + SystemUtil.getOsInfo().name + } + Text( + text = "$platformName\nFPS: $fps", + modifier = modifier, + color = Color.Green, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.body1 + ) + LaunchedEffect(Unit) { + while (true) { + withFrameMillis { ms -> + fpsCount++ + if (fpsCount == 5) { + fps = (5000 / (ms - lastUpdate)).toInt() + lastUpdate = ms + fpsCount = 0 + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/corner/ui/SwingUtil.kt b/composeApp/src/desktopMain/kotlin/com/corner/ui/SwingUtil.kt index d0b5efd..7bfd004 100644 --- a/composeApp/src/desktopMain/kotlin/com/corner/ui/SwingUtil.kt +++ b/composeApp/src/desktopMain/kotlin/com/corner/ui/SwingUtil.kt @@ -1,8 +1,10 @@ package com.corner.ui +import java.awt.Cursor import java.awt.Dimension import java.awt.Point import java.awt.Toolkit +import java.awt.image.BufferedImage import javax.swing.SwingUtilities object SwingUtil { @@ -24,6 +26,12 @@ object SwingUtil { return dp * resolution / 160 } + fun hideCursor(): Cursor { + val toolkit = Toolkit.getDefaultToolkit() + val image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB) + return toolkit.createCustomCursor(image, Point(0, 0), "InvisibleCursor") + } + /** * 右下 */ diff --git a/composeApp/src/desktopMain/kotlin/main.kt b/composeApp/src/desktopMain/kotlin/main.kt index dcb0eb7..3a68e1d 100644 --- a/composeApp/src/desktopMain/kotlin/main.kt +++ b/composeApp/src/desktopMain/kotlin/main.kt @@ -19,6 +19,7 @@ import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.jetbrains.lifecycle.LifecycleController import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.corner.bean.SettingStore +import com.corner.catvodcore.viewmodel.GlobalModel import com.corner.init.Init import com.corner.init.generateImageLoader import com.corner.ui.SwingUtil @@ -33,8 +34,9 @@ import java.awt.* private val log = LoggerFactory.getLogger("main") + @OptIn(ExperimentalDecomposeApi::class) -fun main(){ +fun main() { launchErrorCatcher() printSystemInfo() application { @@ -45,37 +47,43 @@ fun main(){ ) } + val windowState = rememberWindowState( + size = Util.getPreferWindowSize(800, 800), position = WindowPosition.Aligned(Alignment.Center) + ) + GlobalModel.windowState = windowState + LaunchedEffect(Unit) { - launch(Dispatchers.Default){ + launch(Dispatchers.Default) { Init.start() } } - val windowState = rememberWindowState( - size = Util.getPreferWindowSize(800, 800), position = WindowPosition.Aligned(Alignment.Center) - ) LifecycleController(lifecycle, windowState) val contextMenuRepresentation = if (isSystemInDarkTheme()) DarkDefaultContextMenuRepresentation else LightDefaultContextMenuRepresentation Window( - onCloseRequest = ::exitApplication, icon = painterResource("/icon/TV-icon-s.png"), title = "TV", + onCloseRequest = ::exitApplication, icon = painterResource("pic/TV-icon-s.png"), title = "TV", state = windowState, undecorated = true, - transparent = false + transparent = false, + ) { + window.minimumSize = Dimension(800, 600) CompositionLocalProvider( LocalImageLoader provides remember { generateImageLoader() }, LocalContextMenuRepresentation provides remember { contextMenuRepresentation } ) { - RootContent(component = root, modifier = Modifier.fillMaxSize(), windowState){ + RootContent(component = root, modifier = Modifier.fillMaxSize(), windowState) { + window.isVisible = false SettingStore.write() Init.stop() exitApplication() } } } + } } @@ -91,9 +99,9 @@ fun printSystemInfo() { log.info("系统信息:{}", s.toString()) } -private fun getSystemPropAndAppend(key:String, s:StringBuilder){ +private fun getSystemPropAndAppend(key: String, s: StringBuilder) { val v = SystemPropsUtil.get(key) - if(v.isNotBlank()){ + if (v.isNotBlank()) { s.append(key).append(":").append(v).append("\n") } } diff --git a/composeApp/src/desktopMain/resources/META-INF/services/uk.co.caprica.vlcj.factory.discovery.provider.DiscoveryDirectoryProvider b/composeApp/src/desktopMain/resources/META-INF/services/uk.co.caprica.vlcj.factory.discovery.provider.DiscoveryDirectoryProvider new file mode 100644 index 0000000..368d67c --- /dev/null +++ b/composeApp/src/desktopMain/resources/META-INF/services/uk.co.caprica.vlcj.factory.discovery.provider.DiscoveryDirectoryProvider @@ -0,0 +1 @@ +com.corner.init.CustomDirectoryDiscovery \ No newline at end of file diff --git a/composeApp/src/desktopMain/resources/res/windows/MPC-HC.2.2.1.x64.zip b/composeApp/src/desktopMain/resources/res/windows/MPC-HC.2.2.1.x64.zip deleted file mode 100644 index 1254131..0000000 Binary files a/composeApp/src/desktopMain/resources/res/windows/MPC-HC.2.2.1.x64.zip and /dev/null differ diff --git a/composeApp/src/desktopMain/rules.pro b/composeApp/src/desktopMain/rules.pro index 0b28a7a..39249af 100644 --- a/composeApp/src/desktopMain/rules.pro +++ b/composeApp/src/desktopMain/rules.pro @@ -7,10 +7,14 @@ #-keep class org.bouncycastle.** {*;} -keep class ch.qos.** {*;} -keep class org.eclipse.jetty.** {*;} +-keep class org.eclipse.jetty.** {*;} #-keep class reactor.blockhound.** {*;} #-keep class com.oracle.svm.** {*;} #-keep class com.sun.activation.** {*;} #-keep class org.graalvm.nativeimage.** {*;} +-keep class com.sun.jna.** {*;} +-keep class javax.swing.** {*;} + #tls 1.3 #-keep class org.osgi.** {*;} -keep class com.google.appengine.** {*;} @@ -61,6 +65,8 @@ -keep class androidx.compose.ui.** {*;} -keep class androidx.compose.runtime.** {*;} + + -dontwarn org.jboss.marshalling.** -dontwarn org.conscrypt.** -dontwarn org.eclipse.jetty.** diff --git a/composeApp/src/desktopTest/kotlin/VideoPlayerTest.kt b/composeApp/src/desktopTest/kotlin/VideoPlayerTest.kt deleted file mode 100644 index f07ebc1..0000000 --- a/composeApp/src/desktopTest/kotlin/VideoPlayerTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.singleWindowApplication -import com.corner.ui.VideoPlayer -import com.corner.ui.rememberVideoPlayerState -import java.awt.Dimension - -const val VIDEO_URL = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" - -fun main() { - singleWindowApplication(title = "Video Player") { - // See https://github.com/JetBrains/compose-multiplatform/issues/2285 - window.minimumSize = Dimension(700, 560) - MaterialTheme { - App() - } - } -} - -@Composable -fun App() { - val state = rememberVideoPlayerState() - /* - * Could not use a [Box] to overlay the controls on top of the video. - * See https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials/Swing_Integration - * Related issues: - * https://github.com/JetBrains/compose-multiplatform/issues/1521 - * https://github.com/JetBrains/compose-multiplatform/issues/2926 - */ - Column { - VideoPlayer( - url = VIDEO_URL, - state = state, - onFinish = state::stopPlayback, - modifier = Modifier - .fillMaxWidth() - .height(400.dp) - ) - Slider( - value = state.progress.value.fraction, - onValueChange = { state.seek = it }, - modifier = Modifier.fillMaxWidth() - ) - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text("Timestamp: ${state.progress.value.timeMillis} ms", modifier = Modifier.width(180.dp)) - IconButton(onClick = state::toggleResume) { - Icon( - painter = painterResource("${if (state.isResumed) "pause" else "play"}.svg"), - contentDescription = "Play/Pause", - modifier = Modifier.size(32.dp) - ) - } - IconButton(onClick = state::toggleFullscreen) { - Icon( - painter = painterResource("${if (state.isFullscreen) "exit" else "enter"}-fullscreen.svg"), - contentDescription = "Toggle fullscreen", - modifier = Modifier.size(32.dp) - ) - } - Speed( - initialValue = state.speed, - modifier = Modifier.width(104.dp) - ) { - state.speed = it ?: state.speed - } - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource("volume.svg"), - contentDescription = "Volume", - modifier = Modifier.size(32.dp) - ) - // TODO: Make the slider change volume in logarithmic manner - // See https://www.dr-lex.be/info-stuff/volumecontrols.html - // and https://ux.stackexchange.com/q/79672/117386 - // and https://dcordero.me/posts/logarithmic_volume_control.html - Slider( - value = state.volume, - onValueChange = { state.volume = it }, - modifier = Modifier.width(100.dp) - ) - } - } - } -} - -/** - * See [this Stack Overflow post](https://stackoverflow.com/a/67765652). - */ -@Composable -fun Speed( - initialValue: Float, - modifier: Modifier = Modifier, - onChange: (Float?) -> Unit -) { - var input by remember { mutableStateOf(initialValue.toString()) } - OutlinedTextField( - value = input, - modifier = modifier, - singleLine = true, - leadingIcon = { - Icon( - painter = painterResource("speed.svg"), - contentDescription = "Speed", - modifier = Modifier.size(28.dp) - ) - }, - onValueChange = { - input = if (it.isEmpty()) { - it - } else if (it.toFloatOrNull() == null) { - input // Old value - } else { - it // New value - } - onChange(input.toFloatOrNull()) - } - ) -} \ No newline at end of file diff --git a/composeApp/src/desktopTest/kotlin/commonTest.kt b/composeApp/src/desktopTest/kotlin/commonTest.kt index 07adadf..680d01e 100644 --- a/composeApp/src/desktopTest/kotlin/commonTest.kt +++ b/composeApp/src/desktopTest/kotlin/commonTest.kt @@ -1,30 +1,16 @@ -import com.corner.bean.Hot -import com.github.catvod.bean.Doh -import com.google.common.collect.Lists import com.corner.catvodcore.bean.Result -import com.corner.catvodcore.loader.JarLoader -import com.corner.catvodcore.util.Http import com.corner.catvodcore.util.Jsons import com.corner.catvodcore.util.KtorClient import com.corner.catvodcore.util.Urls -import com.corner.init.Init import com.corner.server.KtorD -import com.github.catvod.crawler.Spider -import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* -import io.ktor.utils.io.jvm.javaio.* import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.encodeToJsonElement -import java.io.File -import java.io.FileInputStream import java.net.URL -import java.net.URLClassLoader import java.util.* import kotlin.test.Test @@ -36,31 +22,18 @@ class commonTest { // private val fileUrl = "file://E:/Archives/compose-mutiplatform-workspace/0821.json" @Test - fun parseConfigTest() { - Http.setDoh(Doh.defaultDoh()[1]) -// parseConfig(fileUrl, false).init() - val spider = JarLoader.getSpider("", "csp_Wogg", "{\n" + - " \"token\": \"影視天下第一\",\n" + - " \"filter\": \"https://fm.t4tv.hz.cz/json/wogg.json\"\n" + - " }", "./jar/fan.txt;md5;364c0f012e73a8801a69900fc25ae9c1") -// println(Spider.safeDns()) - val homeContent = spider?.homeContent(false) -// val homeVideoContent = spider?.homeContent(filter = false) - val detailContent = spider?.detailContent(listOf("/index.php/voddetail/82368.html")) - val playerContent = spider?.playerContent( - "轉存原畫#01", - "5CNb1zzo7z9+6572dcc537aa2f73e51e499d9a14280cdcda9eaa", - Lists.newArrayList() - ) - - println() - } - @Test fun exceptionTest(){ val e = Exception("test") e.stackTraceToString() } + @Test + fun computeTest(){ + var a = 1 + a.plus(1) + println(a) + } + @Test fun splitTest() { val s = "$$$$$$" @@ -101,17 +74,17 @@ class commonTest { */ @Test fun classLoaderTest() { - val urlClassLoader = - URLClassLoader( - arrayOf( - File("F:\\sync\\compose-mutiplatform-workspace\\CatVodSpider\\CatVodSpider\\build\\libs\\CatVodSpider-1.0-SNAPSHOT.jar").toURI() - .toURL() - ), commonTest::class.java.classLoader - ) - println(commonTest::class.java.classLoader) - val loadClass: Spider = urlClassLoader.loadClass("com.github.catvod.spider.Wogg").getDeclaredConstructor().newInstance() as Spider - - loadClass.init() +// val urlClassLoader = +// URLClassLoader( +// arrayOf( +// File("F:\\sync\\compose-mutiplatform-workspace\\CatVodSpider\\CatVodSpider\\build\\libs\\CatVodSpider-1.0-SNAPSHOT.jar").toURI() +// .toURL() +// ), commonTest::class.java.classLoader +// ) +// println(commonTest::class.java.classLoader) +// val loadClass: Spider = urlClassLoader.loadClass("com.github.catvod.spider.Wogg").getDeclaredConstructor().newInstance() as Spider +// +// loadClass.init() // val exists = // File("F:\\sync\\compose-mutiplatform-workspace\\TV-Multiplatform\\shared\\src\\desktopTest\\resources\\7638addc233624a31deb7c569a6bcbc5.jar").exists() @@ -134,12 +107,6 @@ class commonTest { } } - @org.junit.Test - fun jsonTest() { - val t = Jsons.decodeFromStream(FileInputStream("E:\\Archives\\compose-mutiplatform-workspace\\TV-Multiplatform\\shared\\src\\desktopTest\\resources\\homeContent.json")) - println(t) - } - @Test fun playerTest(){ val string = Jsons.decodeFromString( @@ -161,15 +128,6 @@ class commonTest { val encodeToJsonElement = Jsons.encodeToJsonElement(s) } - @Test - fun fileTest(){ - val file = - File("file:\\E:\\Archives\\compose-mutiplatform-workspace\\CatVodSpider\\CatVodSpider\\build\\libs\\CatVodSpider-1.0-SNAPSHOT.jar") - - println(file.exists()) - - } - @Test fun flowTest(){ runBlocking { @@ -200,5 +158,6 @@ class commonTest { ) println(convert) } + } diff --git a/composeApp/src/desktopTest/resources/config.json b/composeApp/src/desktopTest/resources/config.json index e6f14df..41f0280 100644 --- a/composeApp/src/desktopTest/resources/config.json +++ b/composeApp/src/desktopTest/resources/config.json @@ -16,9 +16,73 @@ "name": "BD|首页", "type": 3, "api": "csp_BD", - "searchable": 0, - "changeable": 1, + "searchable": 1, + "changeable": 0, + "ext": { + } + }, + { + "key": "NN", + "name": "泥泥|首页", + "type": 3, + "api": "csp_NiNi", + "searchable": 1, + "changeable": 1 + }, + { + "key": "ng", + "name": "南瓜", + "type": 3, + "api": "csp_NG", + "searchable": 1, + "changeable": 0, + "ext": { + } + }, + { + "key": "ChangZhang", + "name": "厂长影视", + "type": 3, + "api": "csp_ChangZhang", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, + { + "key": "Zxzj", + "name": "在线之家", + "type": 3, + "api": "csp_Zxzj", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, + { + "key": "Mtyy", + "name": "麦田影院", + "type": 3, + "api": "csp_Mtyy", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, + { + "key": "bili", + "name": "哔哩", + "type": 3, + "api": "csp_Bili", + "searchable": 1, + "changeable": 0, "ext": { + "json": "https://gh-proxy.com/https://raw.githubusercontent.com/gaotianliuyun/gao/master/json/bili.json", + "type": "帕梅拉#太极拳#广场舞#演唱会", + "cookie": "" } }, { diff --git a/composeApp/src/desktopTest/resources/config1.json b/composeApp/src/desktopTest/resources/config1.json index 156cd59..f42c187 100644 --- a/composeApp/src/desktopTest/resources/config1.json +++ b/composeApp/src/desktopTest/resources/config1.json @@ -21,6 +21,72 @@ "ext": { } }, + { + "key": "ng", + "name": "南瓜", + "type": 3, + "api": "csp_NG", + "searchable": 1, + "changeable": 0, + "ext": { + } + }, + { + "key": "ChangZhang", + "name": "厂长影视", + "type": 3, + "api": "csp_ChangZhang", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, + { + "key": "Zxzj", + "name": "在线之家", + "type": 3, + "api": "csp_Zxzj", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, + { + "key": "Mtyy", + "name": "麦田影院", + "type": 3, + "api": "csp_Mtyy", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, + { + "key": "bili", + "name": "哔哩", + "type": 3, + "api": "csp_Bili", + "searchable": 1, + "changeable": 0, + "ext": { + "type": "帕梅拉#太极拳#广场舞#演唱会", + "cookie": "" + } + }, + { + "key": "Liangzi", + "name": "量子影视", + "type": 3, + "api": "csp_Liangzi", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, { "key": "BD", "name": "BD|首页", diff --git a/composeApp/src/desktopTest/resources/config3.json b/composeApp/src/desktopTest/resources/config3.json index 666edcf..7156b5c 100644 --- a/composeApp/src/desktopTest/resources/config3.json +++ b/composeApp/src/desktopTest/resources/config3.json @@ -31,6 +31,39 @@ "changeable": 0, "ext": "https://gh-proxy.com/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/json/alist.json" }, + { + "key": "ChangZhang", + "name": "厂长影视", + "type": 3, + "api": "csp_ChangZhang", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, + { + "key": "Zxzj", + "name": "在线之家", + "type": 3, + "api": "csp_Zxzj", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, + { + "key": "Mtyy", + "name": "麦田影院", + "type": 3, + "api": "csp_Mtyy", + "searchable": "1", + "filterable": "0", + "changeable": 0, + "ext": { + } + }, { "key": "PanSou", "name": "盘搜┃搜索", diff --git a/composeApp/src/desktopTest/resources/memo.test b/composeApp/src/desktopTest/resources/memo.test index a680895..fbf76da 100644 --- a/composeApp/src/desktopTest/resources/memo.test +++ b/composeApp/src/desktopTest/resources/memo.test @@ -3,4 +3,5 @@ sync: "F:\ProgramFiles\scoop\apps\potplayer\current\PotPlayerMini64.exe" "F:\ProgramFiles\scoop\apps\mpc-be\current\mpc-be64.exe" "F:\ProgramFiles\scoop\apps\mpv\current\mpv.exe" -home: "E:\Program File\Tools\Scoop\apps\potplayer\current\PotPlayerMini64.exe" \ No newline at end of file +home: "E:\Program File\Tools\Scoop\apps\potplayer\current\PotPlayerMini64.exe" +""E:\Program File\Tools\Scoop\apps\vlc\current\vlc.exe"" \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 7f5616b..03ecd12 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ kotlin.code.style=official #Gradle -org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" \ No newline at end of file +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx4096M" +org.gradle.parallel=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b3e0dde..1148581 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -compose = "1.6.6" -compose-plugin = "1.6.2" +compose = "1.6.11" +compose-plugin = "1.6.11" junit = "4.13.2" kotlin = "1.9.23" diff --git a/readme_images/home.png b/readme_images/home.png index a9cdb26..09120ea 100644 Binary files a/readme_images/home.png and b/readme_images/home.png differ diff --git a/readme_images/search_result.png b/readme_images/search_result.png new file mode 100644 index 0000000..17a1da2 Binary files /dev/null and b/readme_images/search_result.png differ