- Release build 启用 R8/ProGuard + 新建 proguard-rules.pro - MediaDetailScreen: MainScope → rememberCoroutineScope - AppNavHost: openDetail/openHome lambda 记忆化 - VideoPlayerScreen: 提取 PlayerStatusBar 独立 Composable - LibraryScreen: nowMillis 下沉到 ServerStatusLine 内部 - 8个核心数据类添加 @Immutable 注解 - TmdbCalendar: coroutineScope+async 并行化 season 请求 - SearchScreen: derivedStateOf 正确观察 SnapshotStateList - HomeScreen: MediaPoster/ContinueWatchingPoster ImageRequest remember - MediaDetailScreen: cast/recommendations/resources LazyRow 添加 key - RecordsScreen: LaunchedEffect key 用 hashCode 替代 joinToString
152 lines
4.9 KiB
Kotlin
152 lines
4.9 KiB
Kotlin
package com.example.myapplication.data
|
|
|
|
import androidx.annotation.StringRes
|
|
import androidx.compose.runtime.Immutable
|
|
import com.example.myapplication.R
|
|
import java.net.URI
|
|
import java.util.UUID
|
|
|
|
@Immutable
|
|
data class EmbyLibrary(
|
|
val id: String,
|
|
val name: String,
|
|
val type: LibraryType,
|
|
val enabled: Boolean = true,
|
|
)
|
|
|
|
@Immutable
|
|
enum class LibraryType(@StringRes val labelRes: Int) {
|
|
MOVIES(R.string.library_movies),
|
|
TV_SHOWS(R.string.library_tv_shows),
|
|
MUSIC(R.string.library_music),
|
|
BOOKS(R.string.library_books),
|
|
HOME_VIDEOS(R.string.library_home_videos),
|
|
PHOTOS(R.string.library_photos),
|
|
}
|
|
|
|
@Immutable
|
|
data class EmbyServer(
|
|
val id: String,
|
|
val name: String = "",
|
|
val protocol: String = "https",
|
|
val host: String = "",
|
|
val port: String = "",
|
|
val basePath: String = "",
|
|
val username: String = "",
|
|
val password: String = "",
|
|
val apiKey: String = "",
|
|
val userId: String = "",
|
|
val note: String = "",
|
|
val libraries: List<EmbyLibrary> = emptyList(),
|
|
val isEnabled: Boolean = true,
|
|
val isOnline: Boolean = true,
|
|
val lastActive: String = "",
|
|
val iconUrl: String = "",
|
|
val authError: String = "",
|
|
val isSelected: Boolean = false,
|
|
val alternateAddresses: List<EmbyServerAddress> = emptyList(),
|
|
val activeAddressId: String = "",
|
|
val lastAccessedAt: Long = 0L,
|
|
val movieCount: Int? = null,
|
|
val seriesCount: Int? = null,
|
|
) {
|
|
val activeAlternateAddress: EmbyServerAddress?
|
|
get() = alternateAddresses.firstOrNull { it.id == activeAddressId }
|
|
|
|
val fullUrl: String
|
|
get() = activeAlternateAddress?.fullUrl ?: buildServerUrl(protocol, host, port, basePath)
|
|
|
|
val displayHost: String
|
|
get() = activeAlternateAddress?.let { address ->
|
|
if (address.port.isNotBlank()) "${address.host}:${address.port}" else address.host
|
|
} ?: if (port.isNotBlank()) "$host:$port" else host
|
|
|
|
companion object {
|
|
fun generateId(): String = "server_${UUID.randomUUID()}"
|
|
}
|
|
}
|
|
|
|
@Immutable
|
|
data class EmbyServerAddress(
|
|
val id: String = generateId(),
|
|
val name: String = "",
|
|
val protocol: String = "https",
|
|
val host: String = "",
|
|
val port: String = "",
|
|
val basePath: String = "",
|
|
) {
|
|
val fullUrl: String
|
|
get() = buildServerUrl(protocol, host, port, basePath)
|
|
|
|
companion object {
|
|
fun generateId(): String = "address_${UUID.randomUUID()}"
|
|
}
|
|
}
|
|
|
|
data class ParsedServerAddress(
|
|
val protocol: String,
|
|
val host: String,
|
|
val port: String,
|
|
val basePath: String,
|
|
)
|
|
|
|
fun parseServerAddressInput(
|
|
input: String,
|
|
currentProtocol: String,
|
|
currentPort: String,
|
|
currentBasePath: String,
|
|
): ParsedServerAddress {
|
|
val trimmed = input.trim()
|
|
if (trimmed.isEmpty()) {
|
|
return ParsedServerAddress(currentProtocol, "", currentPort, currentBasePath)
|
|
}
|
|
|
|
val schemeMatch = Regex("^(https?)://", RegexOption.IGNORE_CASE).find(trimmed)
|
|
val hasExplicitScheme = schemeMatch != null
|
|
val protocol = schemeMatch?.groupValues?.get(1)?.lowercase()
|
|
?: currentProtocol.lowercase().takeIf { it == "http" } ?: "https"
|
|
val uriText = if (hasExplicitScheme) trimmed else "$protocol://$trimmed"
|
|
val uri = runCatching { URI(uriText) }.getOrNull()
|
|
|
|
var host = uri?.host.orEmpty()
|
|
val explicitPort = uri?.port?.takeIf { it >= 0 }?.toString()
|
|
var path = uri?.rawPath.orEmpty().trim('/')
|
|
|
|
if (host.isEmpty()) {
|
|
val withoutScheme = trimmed.replace(Regex("^(https?)://", RegexOption.IGNORE_CASE), "")
|
|
val authority = withoutScheme.substringBefore('/').substringBefore('?').substringBefore('#')
|
|
val portMatch = Regex("^(.+):(\\d+)$").matchEntire(authority)
|
|
host = portMatch?.groupValues?.get(1) ?: authority
|
|
if (path.isEmpty() && withoutScheme.contains('/')) {
|
|
path = withoutScheme.substringAfter('/').substringBefore('?').substringBefore('#').trim('/')
|
|
}
|
|
}
|
|
|
|
val port = when {
|
|
explicitPort != null -> explicitPort
|
|
hasExplicitScheme && protocol == "https" -> "443"
|
|
hasExplicitScheme -> ""
|
|
currentPort.isNotBlank() -> currentPort
|
|
protocol == "https" -> "443"
|
|
else -> ""
|
|
}
|
|
return ParsedServerAddress(
|
|
protocol = protocol,
|
|
host = host.trim().trimEnd('/'),
|
|
port = port,
|
|
basePath = path.ifBlank { currentBasePath.trim().trim('/') },
|
|
)
|
|
}
|
|
|
|
private fun buildServerUrl(protocol: String, host: String, port: String, basePath: String): String {
|
|
val effectiveProtocol = if (protocol.equals("http", ignoreCase = true)) "http" else "https"
|
|
val cleanHost = host.trim()
|
|
.replace(Regex("^(https?)://", RegexOption.IGNORE_CASE), "")
|
|
.substringBefore('/')
|
|
.trimEnd('/')
|
|
val base = if (port.isNotBlank()) "$effectiveProtocol://$cleanHost:$port" else "$effectiveProtocol://$cleanHost"
|
|
return listOf(base.trimEnd('/'), basePath.trim().trim('/'))
|
|
.filter { it.isNotBlank() }
|
|
.joinToString("/")
|
|
}
|