large server change

enhance Home page with provider selection and improved data loading
create Play page for media playback
enhance PluginManager with provider overrides
improve Search page with provider selection
persist active provider in local storage
update theme options
This commit is contained in:
Cloudburst
2026-01-22 19:59:49 +01:00
parent 30df73645b
commit 890b30c47d
23 changed files with 2004 additions and 198 deletions

View File

@@ -27,6 +27,7 @@ data class RepositoryData(
val name: String? = null,
val url: String,
val iconUrl: String? = null,
val description: String? = null,
val shortcode: String? = null,
val enabled: Boolean = true,
)
@@ -58,6 +59,7 @@ data class ServerConfig(
val plugins: MutableList<PluginData> = mutableListOf(),
val pluginSettings: MutableMap<String, MutableMap<String, MutableMap<String, Any?>>> = mutableMapOf(),
val providerClasses: MutableList<String> = defaultProviderClasses(),
val providerOverrides: MutableList<ProviderOverride> = mutableListOf(),
)
const val PLUGIN_VERSION_NOT_SET = Int.MIN_VALUE
@@ -90,6 +92,8 @@ data class SitePlugin(
data class ExtractorRequest(
val url: String,
val referer: String? = null,
val headers: Map<String, String>? = null,
val userAgent: String? = null,
)
data class LoadLinksRequest(
@@ -101,6 +105,20 @@ data class ProviderRegisterRequest(
val className: String,
)
data class ProviderOverride(
val parentClassName: String,
val name: String,
val url: String,
val lang: String,
)
data class ProviderOverrideRequest(
val parentClassName: String? = null,
val name: String? = null,
val url: String? = null,
val lang: String? = null,
)
data class AccountUpsertRequest(
val id: String? = null,
val type: String,
@@ -138,6 +156,8 @@ data class ProviderInfo(
val supportedTypes: List<String>,
val hasMainPage: Boolean,
val hasQuickSearch: Boolean,
val className: String,
val canBeOverridden: Boolean,
val sourcePlugin: String? = null,
)
@@ -236,6 +256,8 @@ fun MainAPI.toInfo(): ProviderInfo = ProviderInfo(
supportedTypes = supportedTypes.map { it.name },
hasMainPage = hasMainPage,
hasQuickSearch = hasQuickSearch,
className = this::class.qualifiedName ?: this::class.java.name,
canBeOverridden = canBeOverridden,
sourcePlugin = sourcePlugin,
)

View File

@@ -23,8 +23,7 @@ class ProviderRegistry {
return null
}
val instance = clazz.getDeclaredConstructor().newInstance() as MainAPI
addProvider(instance)
instance
if (addProvider(instance)) instance else null
}.getOrElse { error ->
Log.e("Providers", "Failed to register $className: ${error.message}")
null
@@ -44,13 +43,18 @@ class ProviderRegistry {
return true
}
private fun addProvider(api: MainAPI) {
fun registerCustomProvider(api: MainAPI): Boolean {
return addProvider(api)
}
private fun addProvider(api: MainAPI): Boolean {
synchronized(APIHolder.allProviders) {
if (APIHolder.allProviders.any { it.name == api.name }) return
if (APIHolder.allProviders.any { it.name == api.name }) return false
APIHolder.allProviders.add(api)
}
APIHolder.addPluginMapping(api)
api.init()
return true
}
private fun isClassRegistered(className: String): Boolean =

View File

@@ -2,6 +2,7 @@ package com.lagradost.cloudstream3
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
import com.lagradost.api.Log
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.MainPageRequest
@@ -30,8 +31,10 @@ import io.ktor.server.request.receive
import io.ktor.server.request.receiveMultipart
import io.ktor.server.response.respond
import io.ktor.server.response.respondOutputStream
import io.ktor.server.response.respondText
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.head
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
@@ -43,9 +46,12 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URI
import java.net.URLEncoder
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
import java.util.Base64
import kotlin.io.DEFAULT_BUFFER_SIZE
fun main() {
@@ -64,6 +70,8 @@ fun main() {
normalizeRepositories(configStore)
loadPluginsOnStartup(configStore)
cleanupTempPluginArchives(dataDir)
applyProviderOverrides(configStore, providerRegistry)
embeddedServer(Netty, host = initialConfig.server.host, port = initialConfig.server.port) {
install(CallLogging)
@@ -189,6 +197,7 @@ fun main() {
name = request.name ?: manifest?.name,
url = resolvedUrl,
iconUrl = manifest?.iconUrl,
description = manifest?.description,
shortcode = request.shortcode,
enabled = request.enabled
)
@@ -349,16 +358,46 @@ fun main() {
}
route("/proxy") {
get {
val url = call.request.queryParameters["url"]
?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing url"))
val referer = call.request.queryParameters["referer"]
val userAgent = call.request.queryParameters["userAgent"]
val headersEncoded = call.request.queryParameters["headers"]
val headers = decodeHeadersParam(headersEncoded)
val requestHeaders = buildDirectHeaders(call.request.headers, referer, headers, userAgent)
proxyUrl(call, url, requestHeaders, HttpMethod.Get)
}
head {
val url = call.request.queryParameters["url"]
?: return@head call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing url"))
val referer = call.request.queryParameters["referer"]
val userAgent = call.request.queryParameters["userAgent"]
val headersEncoded = call.request.queryParameters["headers"]
val headers = decodeHeadersParam(headersEncoded)
val requestHeaders = buildDirectHeaders(call.request.headers, referer, headers, userAgent)
proxyUrl(call, url, requestHeaders, HttpMethod.Head)
}
post {
val request = call.receive<ExtractorRequest>()
val index = call.request.queryParameters["index"]?.toIntOrNull()
val direct = call.request.queryParameters["direct"]?.toBoolean() == true
if (direct) {
val requestHeaders = buildDirectHeaders(
call.request.headers,
request.referer,
request.headers ?: emptyMap(),
request.userAgent
)
proxyUrl(call, request.url, requestHeaders, HttpMethod.Get)
return@post
}
val (links, error) = collectExtractorLinks(request.url, request.referer)
val selected = selectProxyLink(links, index)
if (selected == null) {
val message = error ?: "No extractor links found"
return@post call.respond(HttpStatusCode.NotFound, ErrorResponse(message))
}
call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*")
proxyExtractorLink(call, selected)
}
}
@@ -367,6 +406,84 @@ fun main() {
get {
call.respond(providerRegistry.listProviders().map { it.toInfo() })
}
route("/overrides") {
get {
call.respond(configStore.load().providerOverrides)
}
post {
val request = call.receive<ProviderOverrideRequest>()
val parentClassName = request.parentClassName?.trim()
val name = request.name?.trim()
val url = request.url?.trim()
if (parentClassName.isNullOrBlank() || name.isNullOrBlank() || url.isNullOrBlank()) {
return@post call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("Missing override fields")
)
}
val base = findBaseProviderByClassName(parentClassName)
?: return@post call.respond(
HttpStatusCode.NotFound,
ErrorResponse("Base provider not found")
)
if (!base.canBeOverridden) {
return@post call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("Base provider cannot be overridden")
)
}
val resolvedLang = request.lang?.trim().takeUnless { it.isNullOrBlank() } ?: base.lang
val overrideEntry = ProviderOverride(
parentClassName = base::class.java.name,
name = name,
url = url.trimEnd('/'),
lang = resolvedLang
)
val config = configStore.load()
if (config.providerOverrides.any { it.name.equals(name, ignoreCase = true) }) {
return@post call.respond(
HttpStatusCode.Conflict,
ErrorResponse("Override name already exists")
)
}
if (providerRegistry.listProviders().any { it.name.equals(name, ignoreCase = true) }) {
return@post call.respond(
HttpStatusCode.Conflict,
ErrorResponse("Provider name already exists")
)
}
val added = registerOverrideProvider(overrideEntry, providerRegistry)
?: return@post call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("Failed to register override")
)
configStore.update { current ->
current.providerOverrides.add(overrideEntry)
current
}
call.respond(overrideEntry)
}
delete("/{name}") {
val name = call.parameters["name"]
?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing name"))
val overrideEntry = configStore.load().providerOverrides.firstOrNull {
it.name.equals(name, ignoreCase = true)
} ?: return@delete call.respond(
HttpStatusCode.NotFound,
ErrorResponse("Override not found")
)
configStore.update { current ->
current.providerOverrides.removeIf { it.name.equals(name, ignoreCase = true) }
current
}
providerRegistry.removeByName(overrideEntry.name)
call.respond(overrideEntry)
}
}
post("/register") {
val request = call.receive<ProviderRegisterRequest>()
val api = providerRegistry.registerByClassName(request.className)
@@ -499,11 +616,60 @@ private fun resolveProjectRoot(): Path {
}
}
private fun cleanupTempPluginArchives(dataDir: Path) {
val cutoffMs = System.currentTimeMillis() - 24L * 60 * 60 * 1000
val dir = dataDir.toFile()
val candidates = dir.listFiles { file ->
file.isFile &&
file.name.endsWith(".cs3", ignoreCase = true) &&
(file.name.startsWith("plugin-download-") || file.name.startsWith("plugin-upload-"))
} ?: return
candidates.forEach { file ->
if (file.lastModified() <= cutoffMs) {
if (file.delete()) {
Log.i("Server", "Deleted stale plugin archive: ${file.absolutePath}")
}
}
}
}
private fun resolveProvider(name: String?): com.lagradost.cloudstream3.MainAPI? {
if (name.isNullOrBlank()) return null
return APIHolder.getApiFromNameNull(name)
}
private fun findBaseProviderByClassName(className: String): com.lagradost.cloudstream3.MainAPI? {
val key = className.trim()
if (key.isBlank()) return null
return synchronized(APIHolder.allProviders) {
APIHolder.allProviders.firstOrNull { api ->
if (!api.canBeOverridden) return@firstOrNull false
val qualified = api::class.qualifiedName ?: api::class.java.name
val simple = api::class.simpleName ?: api::class.java.simpleName
qualified == key || simple == key
}
}
}
private fun registerOverrideProvider(
overrideEntry: ProviderOverride,
providerRegistry: ProviderRegistry
): com.lagradost.cloudstream3.MainAPI? {
val base = findBaseProviderByClassName(overrideEntry.parentClassName) ?: return null
if (!base.canBeOverridden) return null
val url = overrideEntry.url.trim().trimEnd('/')
if (url.isBlank()) return null
val lang = overrideEntry.lang.trim().ifBlank { base.lang }
val instance = base::class.java.getDeclaredConstructor().newInstance().apply {
name = overrideEntry.name.trim()
mainUrl = url
this.lang = lang
canBeOverridden = false
sourcePlugin = base.sourcePlugin
}
return if (providerRegistry.registerCustomProvider(instance)) instance else null
}
private fun normalizeRepositories(configStore: ConfigStore) {
configStore.update { config ->
val normalized = config.repositories.map { repo ->
@@ -534,6 +700,17 @@ private fun loadPluginsOnStartup(configStore: ConfigStore) {
}
}
private fun applyProviderOverrides(configStore: ConfigStore, providerRegistry: ProviderRegistry) {
val overrides = configStore.load().providerOverrides
if (overrides.isEmpty()) return
overrides.forEach { overrideEntry ->
val added = registerOverrideProvider(overrideEntry, providerRegistry)
if (added == null) {
Log.w("Providers", "Failed to apply provider override: ${overrideEntry.name}")
}
}
}
private fun findRepository(configStore: ConfigStore, idOrUrl: String): RepositoryData? {
val repos = configStore.load().repositories
return repos.firstOrNull { it.id == idOrUrl || it.url == idOrUrl }
@@ -710,29 +887,37 @@ private suspend fun autoUpdatePlugins(configStore: ConfigStore, dataDir: Path):
private suspend fun runExtractor(url: String, referer: String?): ExtractorResponse {
return withContext(Dispatchers.IO) {
val links = mutableListOf<ExtractorLink>()
val subtitles = mutableListOf<SubtitleFile>()
val links = mutableListOf<ExtractorLink?>()
val subtitles = mutableListOf<SubtitleFile?>()
val subtitleCallback: (SubtitleFile?) -> Unit = { subtitle ->
if (subtitle != null) subtitles.add(subtitle)
}
val linkCallback: (ExtractorLink?) -> Unit = { link ->
if (link != null) links.add(link)
}
val result = runCatching {
loadExtractor(
url = url,
referer = referer,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
subtitleCallback = subtitleCallback as (SubtitleFile) -> Unit,
callback = linkCallback as (ExtractorLink) -> Unit
)
}
val safeLinks = links.filterNotNull()
val safeSubtitles = subtitles.filterNotNull()
if (result.isFailure) {
val error = result.exceptionOrNull()
return@withContext ExtractorResponse(
success = false,
links = links.map { it.toDto() },
subtitles = subtitles.map { it.toDto() },
links = safeLinks.map { it.toDto() },
subtitles = safeSubtitles.map { it.toDto() },
error = error?.message ?: "Extractor failed",
)
}
ExtractorResponse(
success = result.getOrNull() == true,
links = links.map { it.toDto() },
subtitles = subtitles.map { it.toDto() },
links = safeLinks.map { it.toDto() },
subtitles = safeSubtitles.map { it.toDto() },
)
}
}
@@ -742,17 +927,20 @@ private suspend fun collectExtractorLinks(
referer: String?
): Pair<List<ExtractorLink>, String?> {
return withContext(Dispatchers.IO) {
val links = mutableListOf<ExtractorLink>()
val links = mutableListOf<ExtractorLink?>()
val linkCallback: (ExtractorLink?) -> Unit = { link ->
if (link != null) links.add(link)
}
val result = runCatching {
loadExtractor(
url = url,
referer = referer,
subtitleCallback = {},
callback = { links.add(it) }
callback = linkCallback as (ExtractorLink) -> Unit
)
}
val error = result.exceptionOrNull()?.message
links to error
links.filterNotNull() to error
}
}
@@ -764,48 +952,7 @@ private fun selectProxyLink(links: List<ExtractorLink>, index: Int?): ExtractorL
private suspend fun proxyExtractorLink(call: io.ktor.server.application.ApplicationCall, link: ExtractorLink) {
val requestHeaders = buildProxyHeaders(link, call.request.headers)
val connection = withContext(Dispatchers.IO) {
(URL(link.url).openConnection() as HttpURLConnection).apply {
instanceFollowRedirects = true
requestMethod = "GET"
requestHeaders.forEach { (key, value) ->
setRequestProperty(key, value)
}
connect()
}
}
val statusCode = connection.responseCode
call.response.status(HttpStatusCode.fromValue(statusCode))
connection.contentType?.let { call.response.headers.append(HttpHeaders.ContentType, it) }
if (connection.contentLengthLong >= 0) {
call.response.headers.append(HttpHeaders.ContentLength, connection.contentLengthLong.toString())
}
connection.getHeaderField("Accept-Ranges")?.let {
call.response.headers.append(HttpHeaders.AcceptRanges, it)
}
connection.getHeaderField("Content-Range")?.let {
call.response.headers.append(HttpHeaders.ContentRange, it)
}
connection.getHeaderField("Content-Disposition")?.let {
call.response.headers.append(HttpHeaders.ContentDisposition, it)
}
connection.getHeaderField("Cache-Control")?.let {
call.response.headers.append(HttpHeaders.CacheControl, it)
}
call.respondOutputStream {
withContext(Dispatchers.IO) {
val input = if (statusCode >= 400) {
connection.errorStream ?: connection.inputStream
} else {
connection.inputStream
}
input.use { stream ->
stream.copyTo(this@respondOutputStream)
}
}
}
connection.disconnect()
proxyUrl(call, link.url, requestHeaders)
}
private fun buildProxyHeaders(
@@ -823,31 +970,257 @@ private fun buildProxyHeaders(
return headers
}
private fun buildDirectHeaders(
requestHeaders: io.ktor.http.Headers,
referer: String?,
baseHeaders: Map<String, String>,
userAgent: String?
): Map<String, String> {
var headers = baseHeaders
if (!referer.isNullOrBlank() && headers.keys.none { it.equals("Referer", ignoreCase = true) }) {
headers = headers + mapOf("Referer" to referer)
}
if (!userAgent.isNullOrBlank() && headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) {
headers = headers + mapOf("User-Agent" to userAgent)
}
if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) {
headers = headers + mapOf("User-Agent" to USER_AGENT)
}
val range = requestHeaders[HttpHeaders.Range]
if (range != null && headers.keys.none { it.equals(HttpHeaders.Range, ignoreCase = true) }) {
headers = headers + mapOf(HttpHeaders.Range to range)
}
return headers
}
private fun decodeHeadersParam(encoded: String?): Map<String, String> {
if (encoded.isNullOrBlank()) return emptyMap()
return runCatching {
val decoded = String(java.util.Base64.getDecoder().decode(encoded))
mapper.readValue<Map<String, String>>(decoded)
}.getOrElse { emptyMap() }
}
private suspend fun proxyUrl(
call: io.ktor.server.application.ApplicationCall,
url: String,
requestHeaders: Map<String, String>,
method: HttpMethod = HttpMethod.Get
) {
val upstreamMethod = if (method == HttpMethod.Head) HttpMethod.Get else method
val connection = withContext(Dispatchers.IO) {
(URL(url).openConnection() as HttpURLConnection).apply {
instanceFollowRedirects = true
requestMethod = upstreamMethod.value
requestHeaders.forEach { (key, value) ->
setRequestProperty(key, value)
}
connect()
}
}
val statusCode = connection.responseCode
call.response.status(HttpStatusCode.fromValue(statusCode))
val contentType = connection.contentType
val isM3u8 = isM3u8Response(url, contentType)
contentType?.let { call.response.headers.append(HttpHeaders.ContentType, it) }
if (!isM3u8 && connection.contentLengthLong >= 0) {
if (call.response.headers[HttpHeaders.ContentLength] == null) {
call.response.headers.append(HttpHeaders.ContentLength, connection.contentLengthLong.toString())
}
}
connection.getHeaderField("Accept-Ranges")?.let {
call.response.headers.append(HttpHeaders.AcceptRanges, it)
}
connection.getHeaderField("Content-Range")?.let {
call.response.headers.append(HttpHeaders.ContentRange, it)
}
connection.getHeaderField("Content-Disposition")?.let {
call.response.headers.append(HttpHeaders.ContentDisposition, it)
}
connection.getHeaderField("Cache-Control")?.let {
call.response.headers.append(HttpHeaders.CacheControl, it)
}
if (method == HttpMethod.Head) {
runCatching { connection.inputStream.close() }
connection.disconnect()
return
}
val inputStream = if (statusCode >= 400) {
connection.errorStream ?: connection.inputStream
} else {
connection.inputStream
}
if (isM3u8) {
val playlist = withContext(Dispatchers.IO) {
inputStream.bufferedReader().use { it.readText() }
}
val baseUri = URI(url)
val proxyBase = buildProxyBase(call)
val referer = requestHeaders.entries.firstOrNull { it.key.equals("Referer", ignoreCase = true) }?.value
?: baseUri.toString()
val userAgent = requestHeaders.entries.firstOrNull { it.key.equals("User-Agent", ignoreCase = true) }?.value
val rewritten = rewriteM3u8(
playlist,
baseUri,
proxyBase,
sanitizeProxyHeaders(requestHeaders),
referer,
userAgent
)
connection.disconnect()
call.respondText(
text = rewritten,
contentType = io.ktor.http.ContentType.parse(
contentType ?: "application/vnd.apple.mpegurl"
)
)
return
}
try {
call.respondOutputStream {
try {
withContext(Dispatchers.IO) {
inputStream.use { stream ->
stream.copyTo(this@respondOutputStream)
}
}
} catch (e: io.ktor.util.cio.ChannelWriteException) {
Log.w("Proxy", "Client closed proxy connection early: ${e.message}")
}
}
} catch (e: io.ktor.util.cio.ChannelWriteException) {
Log.w("Proxy", "Client closed proxy connection early: ${e.message}")
} finally {
connection.disconnect()
}
}
private fun isM3u8Response(url: String, contentType: String?): Boolean {
val normalized = contentType?.lowercase().orEmpty()
if (normalized.contains("application/vnd.apple.mpegurl")) return true
if (normalized.contains("application/x-mpegurl")) return true
if (normalized.contains("audio/mpegurl")) return true
return url.lowercase().contains(".m3u8")
}
private fun buildProxyBase(call: io.ktor.server.application.ApplicationCall): String {
val headers = call.request.headers
val scheme = headers["X-Forwarded-Proto"]
?: if (headers["X-Forwarded-Ssl"]?.equals("on", ignoreCase = true) == true) "https" else "http"
val host = headers["X-Forwarded-Host"]
?: headers[HttpHeaders.Host]
?: "127.0.0.1:8080"
return "$scheme://$host"
}
private fun sanitizeProxyHeaders(headers: Map<String, String>): Map<String, String> {
return headers.filterKeys { key ->
!key.equals("Host", ignoreCase = true) &&
!key.equals("Range", ignoreCase = true)
}
}
private fun encodeHeadersParam(headers: Map<String, String>): String? {
if (headers.isEmpty()) return null
val json = mapper.writeValueAsString(headers)
return Base64.getEncoder().encodeToString(json.toByteArray())
}
private fun buildProxyUrl(
proxyBase: String,
targetUrl: String,
referer: String?,
headers: Map<String, String>,
userAgent: String?
): String {
val params = mutableListOf("url" to targetUrl)
if (!referer.isNullOrBlank()) params.add("referer" to referer)
if (!userAgent.isNullOrBlank()) params.add("userAgent" to userAgent)
encodeHeadersParam(headers)?.let { params.add("headers" to it) }
val encoded = params.joinToString("&") { (key, value) ->
"${URLEncoder.encode(key, Charsets.UTF_8)}=${URLEncoder.encode(value, Charsets.UTF_8)}"
}
return "$proxyBase/proxy?$encoded"
}
private fun rewriteM3u8(
playlist: String,
baseUri: URI,
proxyBase: String,
headers: Map<String, String>,
referer: String?,
userAgent: String?
): String {
val uriRegex = Regex("""URI="([^"]+)"""")
return playlist.lineSequence().joinToString("\n") { line ->
val trimmed = line.trim()
if (trimmed.isEmpty()) {
line
} else if (trimmed.startsWith("#")) {
uriRegex.replace(line) { match ->
val raw = match.groupValues[1]
val proxied = proxifyM3u8Url(raw, baseUri, proxyBase, headers, referer, userAgent)
"URI=\"$proxied\""
}
} else {
proxifyM3u8Url(trimmed, baseUri, proxyBase, headers, referer, userAgent)
}
}
}
private fun proxifyM3u8Url(
rawUrl: String,
baseUri: URI,
proxyBase: String,
headers: Map<String, String>,
referer: String?,
userAgent: String?
): String {
val absolute = if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
rawUrl
} else {
baseUri.resolve(rawUrl).toString()
}
return buildProxyUrl(proxyBase, absolute, referer ?: baseUri.toString(), headers, userAgent)
}
private suspend fun runLoadLinks(api: com.lagradost.cloudstream3.MainAPI, request: LoadLinksRequest): ExtractorResponse {
return withContext(Dispatchers.IO) {
val links = mutableListOf<ExtractorLink>()
val subtitles = mutableListOf<SubtitleFile>()
val links = mutableListOf<ExtractorLink?>()
val subtitles = mutableListOf<SubtitleFile?>()
val subtitleCallback: (SubtitleFile?) -> Unit = { subtitle ->
if (subtitle != null) subtitles.add(subtitle)
}
val linkCallback: (ExtractorLink?) -> Unit = { link ->
if (link != null) links.add(link)
}
val result = runCatching {
api.loadLinks(
data = request.data,
isCasting = request.isCasting,
subtitleCallback = { subtitles.add(it) },
callback = { links.add(it) }
subtitleCallback = subtitleCallback as (SubtitleFile) -> Unit,
callback = linkCallback as (ExtractorLink) -> Unit
)
}
val safeLinks = links.filterNotNull()
val safeSubtitles = subtitles.filterNotNull()
if (result.isFailure) {
val error = result.exceptionOrNull()
return@withContext ExtractorResponse(
success = false,
links = links.map { it.toDto() },
subtitles = subtitles.map { it.toDto() },
links = safeLinks.map { it.toDto() },
subtitles = safeSubtitles.map { it.toDto() },
error = error?.message ?: "Load links failed",
)
}
ExtractorResponse(
success = result.getOrNull() == true,
links = links.map { it.toDto() },
subtitles = subtitles.map { it.toDto() },
links = safeLinks.map { it.toDto() },
subtitles = safeSubtitles.map { it.toDto() },
)
}
}

View File

@@ -0,0 +1,206 @@
package com.lagradost.cloudstream3.utils
import android.content.Context
import android.content.SharedPreferences
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.lagradost.cloudstream3.mvvm.logError
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
const val DOWNLOAD_HEADER_CACHE = "download_header_cache"
const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache"
const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key"
const val USER_SELECTED_HOMEPAGE_API = "home_api_used"
const val USER_PROVIDER_API = "user_custom_sites"
const val PREFERENCES_NAME = "rebuild_preference"
class PreferenceDelegate<T : Any>(
val key: String,
val default: T,
) {
private val klass: KClass<out T> = default::class
private var cache: T? = null
operator fun getValue(self: Any?, property: KProperty<*>) =
cache ?: DataStore.getKeyGlobal(key, klass.java).also { newCache ->
if (newCache != null) cache = newCache
} ?: default
operator fun setValue(self: Any?, property: KProperty<*>, t: T?) {
cache = t
if (t == null) {
DataStore.removeKeyGlobal(key)
} else {
DataStore.setKeyGlobal(key, t)
}
}
}
data class Editor(
val editor: SharedPreferences.Editor
) {
fun <T> setKeyRaw(path: String, value: T) {
@Suppress("UNCHECKED_CAST")
if (isStringSet(value)) {
editor.putStringSet(path, value as Set<String>)
} else {
when (value) {
is Boolean -> editor.putBoolean(path, value)
is Int -> editor.putInt(path, value)
is String -> editor.putString(path, value)
is Float -> editor.putFloat(path, value)
is Long -> editor.putLong(path, value)
}
}
}
private fun isStringSet(value: Any?): Boolean {
if (value is Set<*>) {
return value.filterIsInstance<String>().size == value.size
}
return false
}
fun apply() {
editor.apply()
System.gc()
}
}
object DataStore {
val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
private val memoryStore = ConcurrentHashMap<String, String>()
private const val MODE_PRIVATE = 0
private fun getPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE)
}
fun Context.getSharedPrefs(): SharedPreferences {
return getPreferences(this)
}
fun getFolderName(folder: String, path: String): String {
return "${folder}/${path}"
}
fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor {
val editor = context.getSharedPrefs().edit()
return Editor(editor)
}
fun Context.getDefaultSharedPrefs(): SharedPreferences {
return getSharedPrefs()
}
fun Context.getKeys(folder: String): List<String> {
return this.getSharedPrefs().getAll().keys.filter { it.startsWith(folder) }
}
fun Context.removeKey(folder: String, path: String) {
removeKey(getFolderName(folder, path))
}
fun Context.containsKey(folder: String, path: String): Boolean {
return containsKey(getFolderName(folder, path))
}
fun Context.containsKey(path: String): Boolean {
val prefs = getSharedPrefs()
return prefs.contains(path)
}
fun Context.removeKey(path: String) {
try {
val prefs = getSharedPrefs()
if (prefs.contains(path)) {
prefs.edit().remove(path).apply()
}
} catch (e: Exception) {
logError(e)
}
}
fun Context.removeKeys(folder: String): Int {
val keys = getKeys("$folder/")
try {
getSharedPrefs().edit().apply {
keys.forEach { value ->
remove(value)
}
}.apply()
return keys.size
} catch (e: Exception) {
logError(e)
return 0
}
}
fun <T> Context.setKey(path: String, value: T) {
try {
getSharedPrefs().edit().putString(path, mapper.writeValueAsString(value)).apply()
} catch (e: Exception) {
logError(e)
}
}
fun <T> Context.getKey(path: String, valueType: Class<T>): T? {
return try {
val json: String = getSharedPrefs().getString(path, null) ?: return null
json.toKotlinObject(valueType)
} catch (e: Exception) {
null
}
}
fun <T> Context.setKey(folder: String, path: String, value: T) {
setKey(getFolderName(folder, path), value)
}
inline fun <reified T : Any> String.toKotlinObject(): T {
return mapper.readValue(this, T::class.java)
}
fun <T> String.toKotlinObject(valueType: Class<T>): T {
return mapper.readValue(this, valueType)
}
inline fun <reified T : Any> Context.getKey(path: String, defVal: T?): T? {
return try {
val json: String = getSharedPrefs().getString(path, null) ?: return defVal
json.toKotlinObject()
} catch (e: Exception) {
null
}
}
inline fun <reified T : Any> Context.getKey(path: String): T? {
return getKey(path, null)
}
inline fun <reified T : Any> Context.getKey(folder: String, path: String): T? {
return getKey(getFolderName(folder, path), null)
}
inline fun <reified T : Any> Context.getKey(folder: String, path: String, defVal: T?): T? {
return getKey(getFolderName(folder, path), defVal) ?: defVal
}
fun <T> setKeyGlobal(path: String, value: T) {
memoryStore[path] = mapper.writeValueAsString(value)
}
fun <T> getKeyGlobal(path: String, valueType: Class<T>): T? {
val json = memoryStore[path] ?: return null
return runCatching { mapper.readValue(json, valueType) }.getOrNull()
}
fun removeKeyGlobal(path: String) {
memoryStore.remove(path)
}
}

View File

@@ -11,7 +11,8 @@
"@tailwindcss/vite": "^4.1.18",
"daisyui": "^5.5.14",
"svelte-spa-router": "^4.0.1",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"vidstack": "^0.6.15"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
@@ -484,6 +485,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@maverick-js/signals": {
"version": "5.11.5",
"resolved": "https://registry.npmjs.org/@maverick-js/signals/-/signals-5.11.5.tgz",
"integrity": "sha512-/GO94awrwN9ROYZDMTeByordjvbhcm3CMvB/2aL/sEUy9Va8nM/2GmNgOOe+rrooTGnz8/DzO73xomuBRrnYWw==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
@@ -1627,6 +1634,28 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/maverick.js": {
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/maverick.js/-/maverick.js-0.37.0.tgz",
"integrity": "sha512-1Dk/9rienLiihlktVvH04ADC2UJTMflC1fOMVQCCaQAaz7hgzDI5i0p/arFbDM52hFFiIcq4RdXtYz47SgsLgw==",
"license": "MIT",
"dependencies": {
"@maverick-js/signals": "^5.10.3",
"type-fest": "^3.8.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/media-captions": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/media-captions/-/media-captions-0.0.18.tgz",
"integrity": "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1901,6 +1930,18 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/type-fest": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
"integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1923,6 +1964,20 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/vidstack": {
"version": "0.6.15",
"resolved": "https://registry.npmjs.org/vidstack/-/vidstack-0.6.15.tgz",
"integrity": "sha512-pI2aixBuOpu/LSnRgNJ40tU/KFW+x1X+O2bW1hz946ZZShDM5oqRXF9pavDOuckHAHPgUN9HYUr9vUNTBUPF1Q==",
"license": "MIT",
"dependencies": {
"maverick.js": "0.37.0",
"media-captions": "0.0.18",
"type-fest": "^3.8.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -22,6 +22,7 @@
"@tailwindcss/vite": "^4.1.18",
"daisyui": "^5.5.14",
"svelte-spa-router": "^4.0.1",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"vidstack": "^0.6.15"
}
}

View File

@@ -6,6 +6,8 @@
import Search from './pages/Search.svelte';
import Settings from './pages/Settings.svelte';
import PluginManager from './pages/PluginManager.svelte';
import Details from './pages/Details.svelte';
import Play from './pages/Play.svelte';
import { theme } from './stores/theme';
import { onMount } from 'svelte';
@@ -14,6 +16,8 @@
'/search': Search,
'/settings': Settings,
'/plugins': PluginManager,
'/details': Details,
'/play': Play,
};
onMount(() => {

View File

@@ -1,8 +1,11 @@
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080';
export const API_BASE_URL = RAW_API_BASE.replace(/\/+$/, '');
export class CloudstreamAPI {
private baseUrl: string;
constructor(baseUrl: string = '/api') {
this.baseUrl = baseUrl;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
@@ -75,11 +78,11 @@ export class CloudstreamAPI {
});
}
async removePlugin(internalName: string): Promise<any> {
async removePlugin(request: { filePath?: string; repositoryUrl?: string; internalName?: string }): Promise<any> {
return this.request('/plugins', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internalName }),
body: JSON.stringify(request),
});
}
@@ -97,8 +100,39 @@ export class CloudstreamAPI {
return this.request('/providers');
}
async getProviderMainPage(providerName: string): Promise<any> {
return this.request(`/providers/${providerName}/main-page`);
async getProviderOverrides(): Promise<any[]> {
return this.request('/providers/overrides');
}
async addProviderOverride(payload: {
parentClassName: string;
name: string;
url: string;
lang?: string;
}): Promise<any> {
return this.request('/providers/overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
async removeProviderOverride(name: string): Promise<any> {
return this.request(`/providers/overrides/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
}
async getProviderMainPages(providerName: string): Promise<any[]> {
return this.request(`/providers/${providerName}/main-pages`);
}
async getProviderMainPage(providerName: string, options: { data?: string; page?: number } = {}): Promise<any> {
const params = new URLSearchParams();
if (options.data) params.set('data', options.data);
if (options.page) params.set('page', String(options.page));
const query = params.toString();
return this.request(`/providers/${providerName}/main-page${query ? `?${query}` : ''}`);
}
async searchProvider(providerName: string, query: string): Promise<any> {
@@ -109,6 +143,14 @@ export class CloudstreamAPI {
async loadMedia(providerName: string, url: string): Promise<any> {
return this.request(`/providers/${providerName}/load?url=${encodeURIComponent(url)}`);
}
async getProviderLinks(providerName: string, data: string, isCasting: boolean = false): Promise<any> {
return this.request(`/providers/${providerName}/links`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data, isCasting }),
});
}
}
export const api = new CloudstreamAPI();

View File

@@ -1,9 +1,9 @@
@import "tailwindcss";
@import "tailwindcss";
@import "vidstack/styles/defaults.css";
@import "vidstack/styles/community-skin/video.css";
@plugin "daisyui" {
themes: forest, dracula, dim, sunset, autumn, synthwave, pastel, nord, coffee, night, lemonade;
themes: forest, dracula, black, sunset, autumn, synthwave, retro, nord, coffee, night, lemonade, aqua;
}
@theme {
@@ -19,6 +19,24 @@
}
}
:global(media-player.app-player) {
--video-bg: hsl(var(--b1));
--video-border: 1px solid hsl(var(--bc) / 0.12);
--video-border-radius: 16px;
--video-brand: hsl(var(--p));
--video-controls-color: hsl(var(--bc));
--video-scrim-bg: hsl(var(--b1) / 0.2);
--video-font-family: var(--font-body);
--media-focus-ring: 0 0 0 3px hsl(var(--p) / 0.35);
--media-tooltip-bg-color: hsl(var(--b1));
--media-tooltip-color: hsl(var(--bc));
--media-time-color: hsl(var(--bc));
--media-slider-track-bg: hsl(var(--bc) / 0.2);
--media-menu-bg: hsl(var(--b1));
--media-menu-border: 1px solid hsl(var(--bc) / 0.1);
--media-menu-color: hsl(var(--bc));
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
@@ -31,4 +49,4 @@
::-webkit-scrollbar-thumb {
@apply bg-base-content/20 rounded-full hover:bg-base-content/40 transition-colors;
}
}

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="450" viewBox="0 0 300 450">
<rect width="300" height="450" fill="#111827"/>
<rect x="18" y="18" width="264" height="414" rx="16" ry="16" fill="#1f2937" stroke="#374151" stroke-width="4"/>
<rect x="70" y="120" width="160" height="120" rx="8" ry="8" fill="#0f172a" stroke="#374151" stroke-width="3"/>
<circle cx="110" cy="150" r="10" fill="#374151"/>
<path d="M78 220l38-38 28 28 24-24 54 54H78z" fill="#374151"/>
<text x="150" y="300" text-anchor="middle" fill="#9ca3af" font-family="Arial, sans-serif" font-size="20">No Image</text>
</svg>

After

Width:  |  Height:  |  Size: 612 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,5 +1,10 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { location, push } from 'svelte-spa-router';
import { API_BASE_URL } from '../../api';
let healthOk = false;
let healthTimer: number | undefined;
const navItems = [
{ label: 'Home', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', path: '/' },
{ label: 'Search', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z', path: '/search' },
@@ -11,13 +16,36 @@
if (path === '/') return currentPath === '/';
return currentPath.startsWith(path);
}
async function checkHealth() {
try {
const res = await fetch(`${API_BASE_URL}/health`);
if (!res.ok) {
healthOk = false;
return;
}
const data = await res.json();
healthOk = data?.status === 'ok';
} catch {
healthOk = false;
}
}
onMount(() => {
checkHealth();
healthTimer = window.setInterval(checkHealth, 3000);
});
onDestroy(() => {
if (healthTimer) window.clearInterval(healthTimer);
});
</script>
<aside class="w-20 md:w-64 h-full bg-base-200 border-r border-base-100 flex flex-col transition-all duration-300">
<!-- Logo Area -->
<div class="h-16 flex items-center justify-center md:justify-start md:px-6 border-b border-base-100/50">
<div class="size-8 flex items-center justify-center shrink-0 text-primary">
<svg viewBox="0 0 108 108" class="size-full fill-current">
<div class="size-10 flex items-center justify-center shrink-0 text-primary">
<svg viewBox="0 0 108 108" class="size-full fill-current scale-150">
<g transform="translate(29.16, 29.16) scale(0.1755477)">
<path d="M 245.05 148.63 C 242.249 148.627 239.463 149.052 236.79 149.89 C 235.151 141.364 230.698 133.63 224.147 127.931 C 217.597 122.233 209.321 118.893 200.65 118.45 C 195.913 105.431 186.788 94.458 174.851 87.427 C 162.914 80.396 148.893 77.735 135.21 79.905 C 121.527 82.074 109.017 88.941 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.615 148.64 23.582 151.553 18.403 156.733 C 13.223 161.912 10.31 168.945 10.31 176.27 C 10.31 183.595 13.223 190.628 18.403 195.807 C 23.582 200.987 30.615 203.9 37.94 203.9 L 245.05 203.9 C 252.375 203.9 259.408 200.987 264.587 195.807 C 269.767 190.628 272.68 183.595 272.68 176.27 C 272.68 168.945 269.767 161.912 264.587 156.733 C 259.408 151.553 252.375 148.64 245.05 148.64 Z" />
<path d="M 208.61 125 C 208.61 123.22 208.55 121.45 208.48 119.69 C 205.919 119.01 203.296 118.595 200.65 118.45 C 195.913 105.431 186.788 94.458 174.851 87.427 C 162.914 80.396 148.893 77.735 135.21 79.905 C 121.527 82.074 109.017 88.941 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.615 148.64 23.582 151.553 18.403 156.733 C 13.223 161.912 10.31 168.945 10.31 176.27 C 10.31 183.595 13.223 190.628 18.403 195.807 C 23.582 200.987 30.615 203.9 37.94 203.9 L 179 203.9 C 198.116 182.073 208.646 154.015 208.646 125 Z" />
@@ -25,7 +53,10 @@
</g>
</svg>
</div>
<span class="ml-3 font-bold text-lg hidden md:block">CloudStream</span>
<span class="ml-3 font-bold text-lg hidden md:inline-flex items-center gap-2">
CloudStream
<span class="size-2 rounded-full {healthOk ? 'bg-success' : 'bg-base-content/30'}"></span>
</span>
</div>
<!-- Nav Items -->

View File

@@ -1,15 +1,29 @@
<script lang="ts">
import fallbackPoster from '../../assets/poster-fallback.svg';
export let title: string;
export let image: string;
export let subtitle: string | undefined = undefined;
export let onSelect: (() => void) | undefined = undefined;
const fallbackPosterSrc = fallbackPoster;
// Fallback for missing images
function handleImageError(e: Event) {
(e.target as HTMLImageElement).src = 'https://via.placeholder.com/300x450?text=No+Image';
const target = e.target as HTMLImageElement;
if (target.src !== fallbackPosterSrc) {
target.src = fallbackPosterSrc;
}
}
</script>
<div class="card bg-base-100 shadow-xl hover:scale-105 transition-transform duration-200 cursor-pointer overflow-hidden group h-full">
<div
class="card bg-base-100 shadow-xl hover:scale-105 transition-transform duration-200 cursor-pointer overflow-hidden group h-full"
role="button"
tabindex="0"
onclick={() => onSelect?.()}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect?.()}
>
<figure class="aspect-[2/3] relative">
<img
src={image}

View File

@@ -0,0 +1,128 @@
<script lang="ts">
export let providers: any[] = [];
export let selectedValue: string | null = null;
export let valueKey: string = 'name';
export let title = 'Select Provider';
export let description = '';
export let buttonClass = 'btn btn-sm btn-outline';
export let disabled = false;
export let allowSearch = true;
export let onchange: ((event: CustomEvent) => void) | null = null;
let dialog: HTMLDialogElement | null = null;
let search = '';
let currentValue: string | null = null;
$: if (selectedValue !== undefined) {
currentValue = selectedValue ?? null;
}
$: selectedProvider = providers.find(
(provider) => provider?.[valueKey] === currentValue
);
$: filteredProviders = !search
? providers
: providers.filter((provider) => {
const name = provider?.name?.toLowerCase() || '';
const url = provider?.mainUrl?.toLowerCase() || '';
const lang = provider?.lang?.toLowerCase() || '';
const query = search.toLowerCase();
return name.includes(query) || url.includes(query) || lang.includes(query);
});
function openDialog() {
if (disabled) return;
search = '';
dialog?.showModal();
}
function closeDialog() {
dialog?.close();
}
function selectProvider(provider: any) {
const value = provider?.[valueKey];
if (!value) return;
currentValue = value;
const event = new CustomEvent('change', { detail: { value, provider } });
onchange?.(event);
closeDialog();
}
</script>
<button class={buttonClass} onclick={openDialog} disabled={disabled}>
<span class="flex items-center gap-2 truncate">
<span class="font-semibold truncate">
{selectedProvider?.name || title}
</span>
{#if selectedProvider?.lang}
<span class="badge badge-sm badge-neutral">{selectedProvider.lang}</span>
{/if}
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<dialog class="modal" bind:this={dialog}>
<div class="modal-box max-w-5xl">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-bold">{title}</h3>
{#if description}
<p class="text-sm opacity-60 mt-1">{description}</p>
{/if}
</div>
<button class="btn btn-sm btn-ghost" onclick={closeDialog}>Close</button>
</div>
{#if allowSearch}
<div class="mt-4">
<input
class="input input-bordered w-full"
placeholder="Search providers..."
bind:value={search}
/>
</div>
{/if}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-4 max-h-[60vh] overflow-y-auto pr-1">
{#each filteredProviders as provider}
<button
class="card bg-base-100 border border-base-content/10 hover:border-primary/60 text-left transition-colors"
onclick={() => selectProvider(provider)}
>
<div class="card-body p-4 gap-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h4 class="font-bold truncate">{provider.name}</h4>
<p class="text-xs opacity-60 truncate">{provider.mainUrl}</p>
</div>
{#if provider.lang}
<span class="badge badge-sm badge-outline">{provider.lang}</span>
{/if}
</div>
{#if provider.supportedTypes?.length}
<div class="flex flex-wrap gap-1">
{#each provider.supportedTypes.slice(0, 3) as type}
<span class="badge badge-xs badge-ghost">{type}</span>
{/each}
{#if provider.supportedTypes.length > 3}
<span class="badge badge-xs badge-ghost">+{provider.supportedTypes.length - 3}</span>
{/if}
</div>
{/if}
</div>
</button>
{/each}
{#if filteredProviders.length === 0}
<div class="col-span-full py-10 text-center text-sm opacity-60">
No providers found.
</div>
{/if}
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@@ -1,6 +1,10 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
import 'vidstack/define/media-player'
import 'vidstack/define/media-outlet'
import 'vidstack/define/media-poster'
import 'vidstack/define/media-community-skin'
const app = mount(App, {
target: document.getElementById('app')!,

View File

@@ -0,0 +1,374 @@
<script lang="ts">
import { push, querystring } from 'svelte-spa-router';
import { api } from '../api';
import PosterCard from '../components/shared/PosterCard.svelte';
type EpisodeItem = {
season: number;
episode: number;
name: string;
data: string;
posterUrl?: string;
dub?: string;
description?: string;
date?: number;
runTime?: number;
rating?: number;
score?: { data: number };
};
let provider = '';
let mediaUrl = '';
let queryName = '';
let queryPoster = '';
let queryType = '';
let loading = false;
let error: string | null = null;
let details: any = null;
let episodes: EpisodeItem[] = [];
let seasons: number[] = [];
let selectedSeason: number | null = null;
let loadToken = 0;
let currentKey = '';
$: if ($querystring !== undefined) parseQuery($querystring || '');
$: episodes = normalizeEpisodes(details);
$: seasons = [...new Set(episodes.map((e) => e.season))].sort((a, b) => a - b);
$: if (seasons.length > 0 && (selectedSeason === null || !seasons.includes(selectedSeason))) {
selectedSeason = seasons[0];
}
$: if (seasons.length === 0) {
selectedSeason = null;
}
const seriesTypes = new Set(['TvSeries', 'Anime', 'OVA', 'Cartoon']);
$: isSeries = episodes.length > 0 || seriesTypes.has(details?.type);
function parseQuery(query: string) {
const params = new URLSearchParams(query);
const nextProvider = params.get('provider') || '';
const nextUrl = params.get('url') || '';
const nextName = params.get('name') || '';
const nextPoster = params.get('poster') || '';
const nextType = params.get('type') || '';
const key = `${nextProvider}::${nextUrl}`;
if (key === currentKey) return;
currentKey = key;
provider = nextProvider;
mediaUrl = nextUrl;
queryName = nextName;
queryPoster = nextPoster;
queryType = nextType;
loadDetails();
}
async function loadDetails() {
if (!provider || !mediaUrl) {
details = null;
episodes = [];
return;
}
const token = ++loadToken;
loading = true;
error = null;
try {
const data = await api.loadMedia(provider, mediaUrl);
if (token !== loadToken) return;
details = data;
} catch (e: any) {
if (token !== loadToken) return;
details = null;
error = e.message || 'Failed to load details';
} finally {
if (token === loadToken) loading = false;
}
}
function normalizeEpisodes(data: any): EpisodeItem[] {
if (!data?.episodes) return [];
if (Array.isArray(data.episodes)) {
return data.episodes.map((ep: any, index: number) => ({
season: ep.season ?? 1,
episode: ep.episode ?? index + 1,
name: ep.name || `Episode ${ep.episode ?? index + 1}`,
data: ep.data,
posterUrl: ep.posterUrl,
description: ep.description,
date: ep.date ?? ep.airDate ?? ep.air_date,
runTime: ep.runTime ?? ep.runtime ?? ep.duration,
rating: ep.rating,
score: ep.score,
}));
}
if (typeof data.episodes === 'object') {
return Object.entries(data.episodes).flatMap(([dub, list]) => {
if (!Array.isArray(list)) return [];
return list.map((ep: any, index: number) => ({
season: ep.season ?? 1,
episode: ep.episode ?? index + 1,
name: ep.name || `Episode ${ep.episode ?? index + 1}`,
data: ep.data,
posterUrl: ep.posterUrl,
description: ep.description,
date: ep.date ?? ep.airDate ?? ep.air_date,
runTime: ep.runTime ?? ep.runtime ?? ep.duration,
rating: ep.rating,
score: ep.score,
dub,
}));
});
}
return [];
}
function formatScore(value: number | undefined) {
if (value == null) return null;
return Math.round(value / 10000000) / 10;
}
function formatDate(ts: number | string | undefined) {
if (!ts) return '';
const n = typeof ts === 'string' ? Number(ts) : ts;
if (Number.isNaN(n)) return '';
const d = new Date(n);
return d.toLocaleDateString();
}
function formatRuntime(mins: number | undefined) {
if (!mins && mins !== 0) return null;
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function openRec(rec: any) {
const params = new URLSearchParams();
if (rec.apiName) params.set('provider', rec.apiName);
if (rec.url) params.set('url', rec.url);
if (rec.name) params.set('name', rec.name);
if (rec.posterUrl) params.set('poster', rec.posterUrl);
if (rec.type) params.set('type', rec.type);
push(`/details?${params.toString()}`);
}
function playMovie() {
const params = new URLSearchParams();
if (provider) params.set('provider', provider);
if (details?.name || queryName) params.set('name', details?.name || queryName);
if (details?.dataUrl || details?.data || details?.url) {
params.set('data', details?.dataUrl || details?.data || details?.url);
}
if (details?.posterUrl || queryPoster) params.set('poster', details?.posterUrl || queryPoster);
push(`/play?${params.toString()}`);
}
function playEpisode(ep: EpisodeItem) {
const params = new URLSearchParams();
if (provider) params.set('provider', provider);
if (details?.name || queryName) params.set('show', details?.name || queryName);
params.set('episode', ep.name);
params.set('data', ep.data);
if (ep.posterUrl || queryPoster) params.set('poster', ep.posterUrl || queryPoster);
push(`/play?${params.toString()}`);
}
function goBack() {
window.history.back();
}
</script>
<div class="min-h-full pb-20">
{#if loading}
<div class="flex items-center justify-center h-96">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if error}
<div class="p-10 flex flex-col items-center justify-center text-center">
<div class="text-error text-6xl mb-4">!</div>
<h3 class="text-xl font-bold mb-2">Failed to load details</h3>
<p class="text-base-content/60 max-w-md">{error}</p>
<button class="btn btn-primary mt-6" onclick={loadDetails}>Retry</button>
</div>
{:else if details}
{@const heroImage = details?.backgroundPosterUrl || details?.posterUrl || queryPoster}
{@const posterImage = details?.posterUrl || queryPoster}
{@const title = details?.name || queryName || 'Details'}
{@const typeLabel = details?.type || queryType}
<div class="relative w-full h-[55vh] overflow-hidden">
{#if heroImage}
<img src={heroImage} alt="" class="absolute inset-0 w-full h-full object-cover object-top opacity-70" />
{/if}
<div class="absolute inset-0 bg-gradient-to-t from-base-300 via-base-300/70 to-transparent"></div>
<div class="absolute top-6 left-6">
<button class="btn btn-sm btn-ghost" onclick={goBack}>Back</button>
</div>
<div class="absolute bottom-0 left-0 w-full p-8 md:p-12">
<div class="flex flex-col md:flex-row gap-6 items-end">
<div class="w-32 md:w-48 shrink-0">
{#if posterImage}
<img src={posterImage} alt={title} class="w-full rounded-xl shadow-xl border border-base-content/10" />
{:else}
<div class="w-full aspect-[2/3] rounded-xl bg-base-200"></div>
{/if}
</div>
<div class="flex-1">
<div class="flex flex-wrap gap-2 mb-3">
{#if typeLabel}
<span class="badge badge-primary">{typeLabel}</span>
{/if}
{#if details?.year}
<span class="badge badge-ghost">{details.year}</span>
{/if}
{#if details?.duration}
<span class="badge badge-ghost">{formatRuntime(details.duration)}</span>
{/if}
{#if details?.contentRating}
<span class="badge badge-ghost">{details.contentRating}</span>
{/if}
{#if details?.tags}
{#each details.tags as tag}
<span class="badge badge-ghost">{tag}</span>
{/each}
{/if}
{#if details?.score?.data}
<span class="badge badge-ghost">Score {Math.round(details.score.data / 10000000) / 10}</span>
{/if}
</div>
<h1 class="text-3xl md:text-5xl font-black">{title}</h1>
{#if details?.plot}
<p class="mt-4 text-base-content/70 max-w-2xl line-clamp-4">
{details.plot}
</p>
{/if}
<div class="mt-6 flex gap-3">
{#if !isSeries}
<button class="btn btn-primary" onclick={playMovie}>Play</button>
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- recommendations moved below -->
{#if isSeries}
<div class="px-6 md:px-12 mt-8 grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-6">
<div class="space-y-4">
<h2 class="text-lg font-bold">Seasons</h2>
<div class="flex flex-col gap-2 overflow-y-auto max-h-[45vh] pr-2 seasons-scroll">
{#each seasons as season}
<button
class="btn btn-sm justify-start {selectedSeason === season ? 'btn-primary' : 'btn-ghost'}"
onclick={() => selectedSeason = season}
>
Season {season}
</button>
{/each}
{#if seasons.length === 0}
<div class="text-sm text-base-content/60">No seasons available.</div>
{/if}
</div>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold">Episodes</h2>
{#if selectedSeason !== null}
<span class="text-sm text-base-content/60">Season {selectedSeason}</span>
{/if}
</div>
<div class="grid gap-3 overflow-y-auto max-h-[45vh] episodes-scroll">
{#each episodes.filter((ep) => ep.season === selectedSeason) as ep}
<div class="card bg-base-100 border border-base-content/10">
<div class="card-body p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center gap-4">
{#if ep.posterUrl}
<img src={ep.posterUrl} alt="" class="w-16 h-24 object-cover rounded-md" />
{:else}
<div class="w-16 h-24 rounded-md bg-base-200"></div>
{/if}
<div>
<div class="text-sm text-base-content/60">Episode {ep.episode}</div>
<div class="font-semibold">{ep.name}</div>
{#if ep.description}
<div class="text-sm text-base-content/70 mt-1 line-clamp-2">{ep.description}</div>
{/if}
<div class="flex flex-wrap gap-3 mt-2 text-xs text-base-content/60">
{#if ep.runTime}
<div>{formatRuntime(ep.runTime)}</div>
{/if}
{#if ep.rating}
<div>Rating {ep.rating}%</div>
{/if}
{#if ep.score?.data}
<div>Score {formatScore(ep.score.data)}</div>
{/if}
{#if ep.date}
<div>Aired {formatDate(ep.date)}</div>
{/if}
</div>
{#if ep.dub}
<div class="badge badge-xs badge-ghost mt-2">{ep.dub}</div>
{/if}
</div>
</div>
<button class="btn btn-sm btn-primary" onclick={() => playEpisode(ep)}>Play</button>
</div>
</div>
{/each}
{#if selectedSeason !== null && episodes.filter((ep) => ep.season === selectedSeason).length === 0}
<div class="text-sm text-base-content/60">No episodes found for this season.</div>
{/if}
</div>
</div>
</div>
{/if}
{#if details?.recommendations && details.recommendations.length > 0}
<div class="px-6 md:px-12 mt-8">
<h2 class="text-lg font-bold mb-3">Recommendations</h2>
<div class="relative group/carousel">
<div class="flex gap-4 overflow-x-auto pb-6 scroll-smooth snap-x">
{#each details.recommendations as rec}
<div class="w-[160px] md:w-[200px] shrink-0 snap-start">
<PosterCard
title={rec.name}
image={rec.posterUrl}
subtitle={rec.type}
onSelect={() => openRec(rec)}
/>
</div>
{/each}
</div>
</div>
</div>
{/if}
{:else}
<div class="p-20 text-center text-base-content/50">
Select a title to view details.
</div>
{/if}
</div>
<style>
/* Make scrollbars visible in WebKit and Firefox for the scrollable lists */
.seasons-scroll, .episodes-scroll {
scrollbar-width: auto; /* Firefox */
scrollbar-color: rgba(100,100,100,0.6) transparent;
}
.seasons-scroll::-webkit-scrollbar, .episodes-scroll::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.seasons-scroll::-webkit-scrollbar-track, .episodes-scroll::-webkit-scrollbar-track {
background: transparent;
}
.seasons-scroll::-webkit-scrollbar-thumb, .episodes-scroll::-webkit-scrollbar-thumb {
background-color: rgba(100,100,100,0.6);
border-radius: 9999px;
border: 3px solid transparent;
background-clip: padding-box;
}
</style>

View File

@@ -1,12 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import { activeProvider, providers, loadInitialData } from '../stores';
import { api } from '../api';
import PosterCard from '../components/shared/PosterCard.svelte';
import ProviderPicker from '../components/shared/ProviderPicker.svelte';
let mainPageData: any = null;
let mainPageData: any[] | null = null;
let loading = false;
let error: string | null = null;
let loadToken = 0;
onMount(async () => {
if ($providers.length === 0) {
@@ -20,23 +23,63 @@
}
async function loadMainPage(provider: string) {
const token = ++loadToken;
loading = true;
error = null;
mainPageData = null;
try {
const response = await api.getProviderMainPage(provider);
// API returns { items: HomePageList[], hasNext: boolean }
mainPageData = response.items || [];
const pages = await api.getProviderMainPages(provider);
if (token !== loadToken) return;
const entries = (pages || []).filter((page: any) => page?.data);
if (entries.length === 0) {
mainPageData = [];
return;
}
const responses = await Promise.all(
entries.map(async (page: any) => {
if (!page?.data) return null;
try {
const response = await api.getProviderMainPage(provider, { data: page.data });
return { page, response };
} catch (err) {
console.error('Failed to load main page section', page?.name, err);
return null;
}
})
);
if (token !== loadToken) return;
mainPageData = responses.flatMap((entry) => {
if (!entry?.response?.items) return [];
return entry.response.items.map((row: any) => ({
...row,
name: entry.page?.name || row.name,
isHorizontalImages: entry.page?.horizontalImages ?? row.isHorizontalImages,
}));
});
} catch (e: any) {
if (token !== loadToken) return;
error = e.message;
mainPageData = [];
} finally {
loading = false;
if (token === loadToken) loading = false;
}
}
function handleProviderChange(e: Event) {
const target = e.target as HTMLSelectElement;
activeProvider.set(target.value);
function handleProviderChange(event: CustomEvent) {
const value = event.detail?.value;
if (value) activeProvider.set(value);
}
function openDetails(item: any) {
if (!$activeProvider || !item?.url) return;
const params = new URLSearchParams({
provider: $activeProvider,
url: item.url
});
if (item.name) params.set('name', item.name);
if (item.posterUrl) params.set('poster', item.posterUrl);
if (item.type) params.set('type', item.type);
push(`/details?${params.toString()}`);
}
</script>
@@ -45,11 +88,15 @@
<!-- Header / Controls -->
<div class="sticky top-0 z-30 bg-base-300/80 backdrop-blur-md px-6 py-4 flex items-center justify-between border-b border-white/5">
<h2 class="text-xl font-bold text-base-content">Browse</h2>
<select class="select select-sm select-bordered w-full max-w-xs" onchange={handleProviderChange} value={$activeProvider}>
{#each $providers as provider}
<option value={provider.name}>{provider.name} ({provider.lang})</option>
{/each}
</select>
<ProviderPicker
providers={$providers}
selectedValue={$activeProvider}
valueKey="name"
title="Choose Provider"
description="Select which source to browse."
buttonClass="btn btn-sm btn-outline w-full max-w-xs justify-between"
onchange={handleProviderChange}
/>
</div>
{#if loading}
@@ -64,71 +111,76 @@
<button class="btn btn-primary mt-6" onclick={() => $activeProvider && loadMainPage($activeProvider)}>Retry</button>
</div>
{:else if mainPageData}
<!-- Hero Section (Using first item of first row as Hero roughly) -->
{#if mainPageData.length > 0 && mainPageData[0].list.length > 0}
{@const heroItem = mainPageData[0].list[0]}
<div class="relative w-full h-[60vh] overflow-hidden mb-8 group">
<div class="absolute inset-0">
<img src={heroItem.posterUrl} class="w-full h-full object-cover object-top opacity-60 mask-image-gradient" alt="Hero" />
<div class="absolute inset-0 bg-gradient-to-t from-base-300 via-base-300/50 to-transparent"></div>
</div>
<div class="absolute bottom-0 left-0 w-full p-8 md:p-12 flex flex-col items-start gap-4">
<div class="badge badge-primary font-bold">Featured</div>
<h1 class="text-4xl md:text-6xl font-black text-white drop-shadow-lg max-w-3xl leading-tight">
{heroItem.name}
</h1>
<p class="text-base-content/80 max-w-2xl text-lg line-clamp-3 md:line-clamp-2">
{heroItem.type} • Click to watch now
</p>
<div class="flex gap-3 mt-4">
<button class="btn btn-primary btn-lg gap-2 px-8">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Play Now
</button>
<button class="btn btn-neutral btn-lg gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Details
</button>
{#if mainPageData.length === 0}
<div class="p-20 text-center text-base-content/50">
No main page content available for this provider.
</div>
{:else}
<!-- Hero Section (Using first item of first row as Hero roughly) -->
{#if mainPageData.length > 0 && mainPageData[0]?.list?.length > 0}
{@const heroItem = mainPageData[0].list[0]}
<div class="relative w-full h-[60vh] overflow-hidden mb-8 group">
<div class="absolute inset-0">
<img src={heroItem.posterUrl} class="w-full h-full object-cover object-top opacity-60 mask-image-gradient" alt="Hero" />
<div class="absolute inset-0 bg-gradient-to-t from-base-300 via-base-300/50 to-transparent"></div>
</div>
<div class="absolute bottom-0 left-0 w-full p-8 md:p-12 flex flex-col items-start gap-4">
<div class="badge badge-primary font-bold">Featured</div>
<h1 class="text-4xl md:text-6xl font-black text-white drop-shadow-lg max-w-3xl leading-tight">
{heroItem.name}
</h1>
<p class="text-base-content/80 max-w-2xl text-lg line-clamp-3 md:line-clamp-2">
{heroItem.type} • Click to watch now
</p>
<div class="flex gap-3 mt-4">
<button class="btn btn-primary btn-lg gap-2 px-8" onclick={() => openDetails(heroItem)}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Play Now
</button>
<button class="btn btn-neutral btn-lg gap-2" onclick={() => openDetails(heroItem)}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Details
</button>
</div>
</div>
</div>
</div>
{/if}
{/if}
<!-- Content Rows -->
<div class="space-y-12 px-6 md:px-12">
{#each mainPageData as row}
{#if row.list && row.list.length > 0}
<section>
<h3 class="text-xl font-bold text-base-content mb-4 px-1 flex items-center gap-2">
<div class="w-1 h-6 bg-primary rounded-full"></div>
{row.name}
</h3>
<!-- Carousel container -->
<div class="relative group/carousel">
<div class="flex gap-4 overflow-x-auto pb-6 scroll-smooth snap-x no-scrollbar">
{#each row.list as item}
<div class="w-[160px] md:w-[200px] shrink-0 snap-start">
<!-- Content Rows -->
<div class="space-y-12 px-6 md:px-12">
{#each mainPageData as row}
{#if row.list && row.list.length > 0}
<section>
<h3 class="text-xl font-bold text-base-content mb-4 px-1 flex items-center gap-2">
<div class="w-1 h-6 bg-primary rounded-full"></div>
{row.name}
</h3>
<!-- Carousel container -->
<div class="relative group/carousel">
<div class="flex gap-4 overflow-x-auto pb-6 scroll-smooth snap-x">
{#each row.list as item}
<div class="w-[160px] md:w-[200px] shrink-0 snap-start">
<PosterCard
title={item.name}
image={item.posterUrl}
subtitle={item.type}
onSelect={() => openDetails(item)}
/>
</div>
{/each}
</div>
{/each}
</div>
</div>
</div>
</section>
{/if}
{/each}
</div>
</section>
{/if}
{/each}
</div>
{/if}
{:else}
<div class="p-20 text-center text-base-content/50">
Select a provider to start browsing.

View File

@@ -0,0 +1,219 @@
<script lang="ts">
import { querystring } from 'svelte-spa-router';
import { api, API_BASE_URL } from '../api';
type ExtractorLink = {
url: string;
referer: string;
quality: number;
name: string;
source: string;
isM3u8: boolean;
isDash: boolean;
allHeaders?: Record<string, string>;
userAgent?: string | null;
};
let provider = '';
let name = '';
let show = '';
let episode = '';
let data = '';
let poster = '';
let loading = false;
let error: string | null = null;
let links: ExtractorLink[] = [];
let subtitles: any[] = [];
let selectedLink: ExtractorLink | null = null;
let proxySrc = '';
let playerSrc: any = '';
let playerEl: any = null;
let loadToken = 0;
let currentKey = '';
$: if ($querystring !== undefined) parseQuery($querystring || '');
function parseQuery(query: string) {
const params = new URLSearchParams(query);
const nextProvider = params.get('provider') || '';
const nextData = params.get('data') || '';
const key = `${nextProvider}::${nextData}`;
provider = nextProvider;
data = nextData;
name = params.get('name') || '';
show = params.get('show') || '';
episode = params.get('episode') || '';
poster = params.get('poster') || '';
if (!provider || !data || key === currentKey) return;
currentKey = key;
loadLinks();
}
async function loadLinks() {
const token = ++loadToken;
loading = true;
error = null;
links = [];
subtitles = [];
selectedLink = null;
proxySrc = '';
try {
const response = await api.getProviderLinks(provider, data);
if (token !== loadToken) return;
if (!response?.links?.length) {
error = response?.error || 'No playable links found.';
return;
}
links = response.links;
subtitles = response.subtitles || [];
selectLink(getBestLink(links));
} catch (e: any) {
if (token !== loadToken) return;
error = e.message || 'Failed to load playback data.';
} finally {
if (token === loadToken) loading = false;
}
}
function getBestLink(items: ExtractorLink[]) {
return [...items].sort((a, b) => (b.quality || 0) - (a.quality || 0))[0] || null;
}
function selectLink(link: ExtractorLink | null) {
selectedLink = link;
proxySrc = link ? buildProxyUrl(link) : '';
playerSrc = link ? buildPlayerSource(link, proxySrc) : '';
}
$: if (playerEl) {
playerEl.src = playerSrc || '';
}
function buildPlayerSource(link: ExtractorLink, src: string) {
const type = inferMimeType(link);
return type ? { src, type } : src;
}
function buildProxyUrl(link: ExtractorLink) {
const params = new URLSearchParams();
params.set('url', link.url);
if (link.referer) params.set('referer', link.referer);
const headers = { ...(link.allHeaders || {}) };
if (link.userAgent && !headerExists(headers, 'User-Agent')) {
headers['User-Agent'] = link.userAgent;
}
const headersEncoded = encodeHeaders(headers);
if (headersEncoded) params.set('headers', headersEncoded);
return `${API_BASE_URL}/proxy?${params.toString()}`;
}
function encodeHeaders(headers: Record<string, string>) {
try {
const json = JSON.stringify(headers);
return btoa(unescape(encodeURIComponent(json)));
} catch {
return '';
}
}
function headerExists(headers: Record<string, string>, key: string) {
return Object.keys(headers).some((header) => header.toLowerCase() === key.toLowerCase());
}
function subtitleUrl(sub: any) {
const params = new URLSearchParams();
params.set('url', sub.url);
if (sub.headers) {
const headersEncoded = encodeHeaders(sub.headers);
if (headersEncoded) params.set('headers', headersEncoded);
}
return `${API_BASE_URL}/proxy?${params.toString()}`;
}
function inferMimeType(link: ExtractorLink) {
if (link.isDash) return 'application/dash+xml';
if (link.isM3u8) return 'application/x-mpegurl';
const url = link.url.toLowerCase();
if (url.includes('.mp4')) return 'video/mp4';
if (url.includes('.webm')) return 'video/webm';
if (url.includes('.mkv')) return 'video/x-matroska';
if (url.includes('.mov')) return 'video/quicktime';
if (url.includes('.mp3')) return 'audio/mpeg';
if (url.includes('.m4a')) return 'audio/mp4';
if (url.includes('.aac')) return 'audio/aac';
if (url.includes('.ogg') || url.includes('.ogv')) return 'video/ogg';
return 'video/mp4';
}
function goBack() {
window.history.back();
}
</script>
<div class="p-6 md:p-10 space-y-6">
<div class="flex items-center justify-between">
<button class="btn btn-sm btn-ghost" onclick={goBack}>Back</button>
{#if links.length > 1}
<select
class="select select-sm select-bordered"
onchange={(e) => {
const value = (e.target as HTMLSelectElement).value;
const next = links.find((link) => link.url === value) || null;
selectLink(next);
}}
>
{#each links as link}
<option value={link.url} selected={selectedLink?.url === link.url}>
{link.name || link.source}{link.quality}
</option>
{/each}
</select>
{/if}
</div>
<div>
<h1 class="text-2xl md:text-3xl font-bold">
{name || episode || show || 'Playback'}
</h1>
{#if show && episode}
<p class="text-base-content/60">{show}{episode}</p>
{:else if show}
<p class="text-base-content/60">{show}</p>
{/if}
</div>
{#if loading}
<div class="flex items-center justify-center h-80">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{:else if playerSrc}
<media-player
class="app-player w-full"
bind:this={playerEl}
title={name || show || 'Playback'}
poster={poster || undefined}
crossorigin
playsinline
>
<media-outlet>
{#each subtitles as sub}
<track
kind="subtitles"
src={subtitleUrl(sub)}
label={sub.lang || sub.langTag || 'Subtitle'}
srclang={sub.langTag || sub.lang || 'en'}
/>
{/each}
</media-outlet>
<media-community-skin></media-community-skin>
<media-poster></media-poster>
</media-player>
{:else}
<div class="text-base-content/60">Select a title to play.</div>
{/if}
</div>

View File

@@ -1,17 +1,25 @@
<script lang="ts">
import { onMount } from 'svelte';
import { config as configStore, plugins, repositories, loadInitialData } from '../stores';
import { config as configStore, plugins, repositories, providers, loadInitialData } from '../stores';
import { toast } from '../stores/toast';
import { api } from '../api';
import ConfirmModal from '../components/shared/ConfirmModal.svelte';
import ProviderPicker from '../components/shared/ProviderPicker.svelte';
let activeTab = 'installed';
let loading = false;
let repoUrlInput = '';
let repoNameInput = '';
let providerOverrides: any[] = [];
let overrideBaseClass = '';
let overrideName = '';
let overrideUrl = '';
let overrideLang = '';
let overridesLoading = false;
onMount(async () => {
if (!$configStore) await loadInitialData();
await loadOverrides();
});
async function addRepository() {
@@ -30,6 +38,13 @@
}
}
$: overrideableProviders = $providers.filter(
(provider) => provider.className && provider.canBeOverridden !== false
);
$: if (!overrideBaseClass && overrideableProviders.length > 0) {
overrideBaseClass = overrideableProviders[0].className;
}
let confirmRepoDelete: ConfirmModal;
async function removeRepository(id: string) {
@@ -46,6 +61,68 @@
}
}
let confirmOverrideDelete: ConfirmModal;
async function loadOverrides() {
overridesLoading = true;
try {
providerOverrides = await api.getProviderOverrides();
} catch (e) {
console.error(e);
toast.error('Failed to load provider overrides');
} finally {
overridesLoading = false;
}
}
function resolveProviderLabel(parentClassName: string) {
const match = $providers.find((provider) => {
if (!provider.className) return false;
const simple = provider.className.split('.').pop();
return provider.className === parentClassName || simple === parentClassName;
});
return match ? match.name : parentClassName;
}
async function addOverride() {
if (!overrideBaseClass || !overrideName || !overrideUrl) return;
overridesLoading = true;
try {
await api.addProviderOverride({
parentClassName: overrideBaseClass,
name: overrideName.trim(),
url: overrideUrl.trim(),
lang: overrideLang.trim() || undefined,
});
overrideName = '';
overrideUrl = '';
overrideLang = '';
await loadOverrides();
await loadInitialData();
toast.success('Provider override added');
} catch (e) {
console.error(e);
toast.error('Failed to add provider override');
} finally {
overridesLoading = false;
}
}
async function removeOverride(overrideEntry: any) {
if (!await confirmOverrideDelete.show()) return;
overridesLoading = true;
try {
await api.removeProviderOverride(overrideEntry.name);
await loadOverrides();
await loadInitialData();
toast.success('Provider override removed');
} catch (e) {
console.error(e);
toast.error('Failed to remove provider override');
} finally {
overridesLoading = false;
}
}
// File upload handler
async function handleFileUpload(e: Event) {
const input = e.target as HTMLInputElement;
@@ -175,6 +252,11 @@
let confirmUninstall: ConfirmModal;
let pluginToUninstall: any = null;
function handleIconError(e: Event) {
const target = e.currentTarget as HTMLImageElement;
target.classList.add('hidden');
}
async function uninstallPlugin(plugin: any) {
pluginToUninstall = plugin;
if(!await confirmUninstall.show()) {
@@ -233,6 +315,11 @@
onclick={() => activeTab = 'repositories'}>
Repositories
</button>
<button
class="join-item btn {activeTab === 'overrides' ? 'btn-primary' : 'btn-neutral'}"
onclick={() => activeTab = 'overrides'}>
Overrides
</button>
</div>
{/if}
</div>
@@ -255,11 +342,11 @@
<!-- Multi-select TV Types Dropdown -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-outline m-1">
Filter Types {selectedTvTypes.length > 0 ? `(${selectedTvTypes.length})` : ''}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
<div tabindex="0" role="button" class="select select-bordered w-full md:w-auto flex items-center justify-between px-3">
<span>Filter Types {selectedTvTypes.length > 0 ? `(${selectedTvTypes.length})` : ''}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-60 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</div>
<ul tabindex="-1" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 max-h-96 overflow-y-auto flex-nowrap block">
<ul tabindex="-1" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box min-w-[13rem] w-52 max-h-96 overflow-y-auto flex-nowrap block">
{#each uniqueTvTypes as type}
<li>
<label class="label cursor-pointer justify-start">
@@ -288,19 +375,19 @@
<div class="card-body p-5 flex flex-col h-full">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3 w-full overflow-hidden">
{#if plugin.iconUrl}
<img
src={plugin.iconUrl.replace('%size%', '64')}
alt=""
class="size-12 rounded-md object-contain bg-base-300 shrink-0"
onerror={(e) => e.currentTarget.style.display = 'none'}
/>
{:else}
<div class="size-12 rounded-md bg-secondary/20 flex items-center justify-center text-secondary-content shrink-0">
<!-- Monochrome Puzzle Icon Fallback -->
<svg xmlns="http://www.w3.org/2000/svg" class="size-6 opacity-70 fill-current" viewBox="0 0 24 24"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
</div>
{/if}
<div class="size-12 rounded-md bg-base-300 shrink-0 relative overflow-hidden flex items-center justify-center text-base-content/70">
<svg xmlns="http://www.w3.org/2000/svg" class="size-6 opacity-70" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/>
</svg>
{#if plugin.iconUrl}
<img
src={plugin.iconUrl.replace('%size%', '64')}
alt=""
class="absolute inset-0 w-full h-full object-contain bg-base-300"
onerror={handleIconError}
/>
{/if}
</div>
<div class="min-w-0">
<h3 class="font-bold truncate" title={plugin.name}>{plugin.name}</h3>
<p class="text-xs opacity-60 truncate">by {plugin.authors?.join(', ') || 'Unknown'}</p>
@@ -395,20 +482,27 @@
<div class="card-body p-5">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
{#if plugin.iconUrl}
<img src={plugin.iconUrl.replace('%size%', '64')} alt="" class="size-10 rounded-md object-contain bg-base-300" />
{:else}
<div class="size-10 rounded-md bg-primary flex items-center justify-center text-primary-content font-bold">
{plugin.name?.charAt(0) || '?'}
</div>
{/if}
<div class="size-10 rounded-md bg-base-300 shrink-0 relative overflow-hidden flex items-center justify-center text-base-content/70">
<svg xmlns="http://www.w3.org/2000/svg" class="size-5 opacity-70" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/>
</svg>
{#if plugin.iconUrl}
<img
src={plugin.iconUrl.replace('%size%', '64')}
alt=""
class="absolute inset-0 w-full h-full object-contain bg-base-300"
onerror={handleIconError}
/>
{/if}
</div>
<div>
<h3 class="font-bold">{plugin.name || plugin.internalName}</h3>
<p class="text-xs opacity-60">v{plugin.version}{plugin.authors?.join(', ')}</p>
</div>
</div>
<div class="badge badge-sm {plugin.status === 1 ? 'badge-success' : 'badge-warning'}">
{plugin.status === 1 ? 'Working' : 'Issues'}
<div class="flex items-center gap-2 text-xs opacity-70">
<span class="inline-block size-2 rounded-full {plugin.status === 1 ? 'bg-success' : 'bg-warning'}"></span>
<span>{plugin.status === 1 ? 'Working' : 'Issues'}</span>
</div>
</div>
@@ -453,14 +547,25 @@
{#each $repositories as repo}
<div class="alert bg-base-100 shadow-sm border border-base-content/5 flex items-center justify-between">
<div class="flex items-center gap-4">
{#if repo.iconUrl}
<img src={repo.iconUrl} alt="" class="size-8 rounded object-contain" />
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6 opacity-50" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
{/if}
<div class="size-8 rounded bg-base-300 shrink-0 relative overflow-hidden flex items-center justify-center text-base-content/70">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4 opacity-70" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/>
</svg>
{#if repo.iconUrl}
<img
src={repo.iconUrl}
alt=""
class="absolute inset-0 w-full h-full object-contain bg-base-300"
onerror={handleIconError}
/>
{/if}
</div>
<div>
<h3 class="font-bold">{repo.name}</h3>
<div class="text-xs opacity-50 font-mono truncate max-w-md">{repo.url}</div>
{#if repo.description}
<div class="text-xs opacity-70 mt-1 line-clamp-2 max-w-md">{repo.description}</div>
{/if}
</div>
</div>
<div class="flex gap-2">
@@ -475,6 +580,102 @@
{/each}
</div>
</div>
{:else if activeTab === 'overrides'}
<div class="space-y-8">
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body gap-4">
<div>
<h3 class="font-bold text-lg">Add Provider Override</h3>
<p class="text-sm opacity-60">
Create a custom provider by overriding the base URL, name, or language.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Base Provider</span></label>
<ProviderPicker
providers={overrideableProviders}
selectedValue={overrideBaseClass}
valueKey="className"
title="Select Base Provider"
description="Choose the provider to clone."
buttonClass="btn btn-outline w-full justify-between"
onchange={(event) => (overrideBaseClass = event.detail.value)}
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Override Name</span></label>
<input
type="text"
bind:value={overrideName}
placeholder="My Custom Provider"
class="input input-bordered w-full"
/>
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">Override URL</span></label>
<input
type="text"
bind:value={overrideUrl}
placeholder="https://example.com"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Language</span></label>
<input
type="text"
bind:value={overrideLang}
placeholder="en"
class="input input-bordered w-full"
/>
<label class="label">
<span class="label-text-alt opacity-60">Optional, defaults to base provider.</span>
</label>
</div>
</div>
<div class="flex justify-end">
<button
class="btn btn-primary"
onclick={addOverride}
disabled={!overrideBaseClass || !overrideName || !overrideUrl || overridesLoading}
>
{overridesLoading ? 'Saving...' : 'Add Override'}
</button>
</div>
</div>
</div>
<div class="space-y-4">
<h3 class="text-lg font-bold">Current Overrides</h3>
{#if providerOverrides.length === 0}
<div class="py-12 text-center text-sm opacity-60">
No overrides added yet.
</div>
{:else}
<div class="grid gap-3">
{#each providerOverrides as overrideEntry}
<div class="alert bg-base-100 shadow-sm border border-base-content/5 flex items-center justify-between">
<div>
<h4 class="font-bold">{overrideEntry.name}</h4>
<div class="text-xs opacity-60">
Base: {resolveProviderLabel(overrideEntry.parentClassName)}
</div>
<div class="text-xs opacity-50 font-mono truncate max-w-md">{overrideEntry.url}</div>
<div class="text-xs opacity-50">Lang: {overrideEntry.lang}</div>
</div>
<button
class="btn btn-sm btn-ghost text-error"
onclick={() => removeOverride(overrideEntry)}
>
Remove
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<ConfirmModal
@@ -492,4 +693,12 @@
confirmText="Uninstall"
type="error"
/>
<ConfirmModal
bind:this={confirmOverrideDelete}
title="Remove Override"
message="Are you sure you want to remove this override?"
confirmText="Remove"
type="error"
/>
</div>

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import { activeProvider, providers, loadInitialData } from '../stores';
import { api } from '../api';
import PosterCard from '../components/shared/PosterCard.svelte';
import ProviderPicker from '../components/shared/ProviderPicker.svelte';
let query = '';
let searchResults: any[] = [];
@@ -53,6 +55,23 @@
}
if (query) handleSearch();
}
function handleProviderChange(event: CustomEvent) {
const value = event.detail?.value;
if (value) activeProvider.set(value);
}
function openDetails(item: any) {
if (!$activeProvider || !item?.url) return;
const params = new URLSearchParams({
provider: $activeProvider,
url: item.url
});
if (item.name) params.set('name', item.name);
if (item.posterUrl) params.set('poster', item.posterUrl);
if (item.type) params.set('type', item.type);
push(`/details?${params.toString()}`);
}
</script>
<div class="p-6 md:p-12 max-w-7xl mx-auto space-y-8">
@@ -63,12 +82,15 @@
<!-- Search Bar & Provider Select -->
<div class="flex flex-col md:flex-row gap-4">
<div class="join w-full">
<select class="select select-bordered join-item bg-base-200" bind:value={$activeProvider}>
<option disabled selected value={null}>Provider</option>
{#each $providers as provider}
<option value={provider.name}>{provider.name}</option>
{/each}
</select>
<ProviderPicker
providers={$providers}
selectedValue={$activeProvider}
valueKey="name"
title="Provider"
description="Select which source to search."
buttonClass="btn btn-outline join-item bg-base-200 justify-between"
onchange={handleProviderChange}
/>
<div class="relative w-full">
<input
type="text"
@@ -117,7 +139,8 @@
<PosterCard
title={item.name}
image={item.posterUrl}
subtitle={item.type}
subtitle={item.type}
onSelect={() => openDetails(item)}
/>
{/each}
</div>

View File

@@ -7,6 +7,15 @@ export const plugins = writable<any[]>([]);
export const repositories = writable<any[]>([]);
export const activeProvider = writable<string | null>(null);
const ACTIVE_PROVIDER_KEY = 'cloudstream_active_provider';
if (typeof localStorage !== 'undefined') {
activeProvider.subscribe((value) => {
if (value) {
localStorage.setItem(ACTIVE_PROVIDER_KEY, value);
}
});
}
export async function loadInitialData() {
try {
@@ -23,8 +32,15 @@ export async function loadInitialData() {
repositories.set(repos);
if (provs.length > 0) {
// Set first provider as active if none selected (logic could be improved)
activeProvider.update(current => current || provs[0].name);
const stored = typeof localStorage !== 'undefined'
? localStorage.getItem(ACTIVE_PROVIDER_KEY)
: null;
const storedValid = stored && provs.some(p => p.name === stored) ? stored : null;
activeProvider.update(current => {
if (current && provs.some(p => p.name === current)) return current;
if (storedValid) return storedValid;
return provs[0].name;
});
}
} catch (err) {
console.error("Failed to load initial data", err);

View File

@@ -6,15 +6,16 @@ const DEFAULT_THEME = 'forest';
export const themes = [
'forest',
'dracula',
'dim',
'black',
'sunset',
'autumn',
'synthwave',
'pastel',
'retro',
'nord',
'coffee',
'night',
'lemonade'
'lemonade',
'aqua'
];
function createThemeStore() {

View File

@@ -8,6 +8,9 @@ export default defineConfig({
svelte(),
tailwindcss()
],
optimizeDeps: {
exclude: ['vidstack'],
},
server: {
proxy: {
'/api': {