Merge pull request #3165 from team-spotube/ios-crash-fix

Ios crash fix
This commit is contained in:
Kingkor Roy Tirtho 2026-10-26 15:16:29 +06:00 committed by GitHub
commit a7e47d0e00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 501 additions and 175 deletions

View File

@ -19,9 +19,13 @@
## Dependencies ## Dependencies
- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. - `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations.
- Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). - Kotlin: `2.4.21`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`).
- **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority). - **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority).
## Networking
- Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop).
- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails.
## GraalVM native image (desktop) ## GraalVM native image (desktop)
- Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`. - Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`.
- Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM. - Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM.

View File

@ -232,7 +232,6 @@ kotlin {
implementation(libs.ktor.client.logging) implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
implementation(libs.ktor.server.core) implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio) implementation(libs.ktor.server.cio)

View File

@ -162,14 +162,6 @@ actual class NewPipeService {
* "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded * "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded
* aac/mp4 DASH/HLS manifests always won instead. * aac/mp4 DASH/HLS manifests always won instead.
*/ */
private fun normalizeCodec(codec: String?): String {
val value = codec.orEmpty()
return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value
}
private fun normalizeContainer(suffix: String): String =
if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix
private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) { private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) {
DeliveryMethod.DASH -> StreamProtocol.DASH DeliveryMethod.DASH -> StreamProtocol.DASH
DeliveryMethod.HLS -> StreamProtocol.HLS DeliveryMethod.HLS -> StreamProtocol.HLS

View File

@ -17,6 +17,8 @@
package org.spotube.spotube.core.di package org.spotube.spotube.core.di
import org.koin.core.module.dsl.onClose
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository
@ -108,6 +110,7 @@ expect val platformModules: Module
val sharedModules = module { val sharedModules = module {
includes(navigationModule) includes(navigationModule)
singleOf(::Database) singleOf(::Database)
single { HttpClientFactory() } withOptions { onClose { it?.close() } }
singleOf(::WebViewController) singleOf(::WebViewController)
@ -164,7 +167,7 @@ val sharedModules = module {
viewModelOf(::WelcomeViewModel) viewModelOf(::WelcomeViewModel)
// Updates // Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() } single { AppUpdateService(get(), get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
// Downloads // Downloads
@ -273,7 +276,7 @@ val sharedModules = module {
createdAtStart() createdAtStart()
} }
single { RemoteControlHandler(get(), get(), get(), get()) } single { RemoteControlHandler(get(), get(), get(), get()) }
single { RemoteControlClient() } single { RemoteControlClient(get()) }
singleOf(::DeviceDiscoveryService) singleOf(::DeviceDiscoveryService)
single { RemoteControlService(get(), get(), get()) } withOptions { single { RemoteControlService(get(), get(), get()) } withOptions {
createdAtStart() createdAtStart()

View File

@ -40,7 +40,7 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.polymorphic
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
val TOP_LEVEL_ROUTES = setOf( val TOP_LEVEL_ROUTES: Set<NavKey> = setOf(
Routes.Home, Routes.Home,
Routes.Search, Routes.Search,
Routes.Library, Routes.Library,

View File

@ -0,0 +1,60 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.network
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* The single place the app builds Ktor [HttpClient]s.
*
* It owns one client on the platform's default engine (Darwin on iOS, OkHttp on
* Android/desktop), so every client shares that engine's connection pool and
* no code path can accidentally pick an engine that doesn't work on a platform.
*
* - [default] and [json] are shared: use them directly and never close them.
* - [create] derives a client with extra configuration (timeouts, WebSockets,
* default request, …). It reuses the shared engine; the caller owns the
* returned client and may close it without affecting anyone else.
*/
class HttpClientFactory : AutoCloseable {
private val root = HttpClient()
/** Plain client with no plugins installed. */
val default: HttpClient get() = root
/** Client that (de)serializes JSON bodies via [HttpJson]. */
val json: HttpClient by lazy {
create { install(ContentNegotiation) { json(HttpJson) } }
}
fun create(block: HttpClientConfig<*>.() -> Unit): HttpClient = root.config(block)
override fun close() {
root.close()
}
}
/** Lenient JSON used for HTTP bodies: remote APIs add fields over time. */
val HttpJson = Json {
ignoreUnknownKeys = true
isLenient = true
}

View File

@ -50,3 +50,13 @@ data class MediaStream(
val bitrate: Int, val bitrate: Int,
val isAudio: Boolean, val isAudio: Boolean,
val isVideo: Boolean) val isVideo: Boolean)
/** Maps NewPipe's codec names onto the plugin's AudioFormat names (mp4a.40.2 → aac). */
internal fun normalizeCodec(codec: String?): String {
val value = codec.orEmpty()
return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value
}
/** Maps NewPipe's file suffixes onto the plugin's AudioFormat containers (m4a → mp4). */
internal fun normalizeContainer(suffix: String): String =
if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix

View File

@ -17,8 +17,8 @@
package org.spotube.spotube.core.remote package org.spotube.spotube.core.remote
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.request.url import io.ktor.client.request.url
@ -49,7 +49,7 @@ import spotube.composeapp.generated.resources.devices_error_send_failed
* WebSocket client for controlling a remote Spotube instance. * WebSocket client for controlling a remote Spotube instance.
* Connects to the remote device's `/control` endpoint and sends commands. * Connects to the remote device's `/control` endpoint and sends commands.
*/ */
class RemoteControlClient { class RemoteControlClient(httpClientFactory: HttpClientFactory) {
private val logger = Logger.withTag("RemoteControlClient") private val logger = Logger.withTag("RemoteControlClient")
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@ -59,7 +59,7 @@ class RemoteControlClient {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val httpClient = HttpClient { private val httpClient = httpClientFactory.create {
install(WebSockets) install(WebSockets)
install(HttpTimeout) { install(HttpTimeout) {
connectTimeoutMillis = 10_000 connectTimeoutMillis = 10_000

View File

@ -17,16 +17,15 @@
package org.spotube.spotube.core.server package org.spotube.spotube.core.server
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.remote.RemoteControlHandler import org.spotube.spotube.core.remote.RemoteControlHandler
import org.spotube.spotube.modules.settings.SettingsViewModel import org.spotube.spotube.modules.settings.SettingsViewModel
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
@ -60,11 +59,12 @@ class LocalServer(
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager, private val cacheManager: CacheManager,
private val remoteControlHandler: RemoteControlHandler) : KoinComponent { private val remoteControlHandler: RemoteControlHandler,
httpClientFactory: HttpClientFactory) : KoinComponent {
val logger by injectLogger<LocalServer>() val logger by injectLogger<LocalServer>()
private val httpClient = HttpClient { private val httpClient = httpClientFactory.create {
// A stalled upstream connection must not wedge the CIO dispatcher thread // A stalled upstream connection must not wedge an engine thread
// forever. Only the connect phase is bounded — the proxy streams long // forever. Only the connect phase is bounded — the proxy streams long
// audio bodies, so request/socket timeouts would cut playback short. // audio bodies, so request/socket timeouts would cut playback short.
install(HttpTimeout) { install(HttpTimeout) {

View File

@ -21,7 +21,10 @@ package org.spotube.spotube.core.server
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.cio.CIO import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.connector
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.application.serverConfig
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
@ -37,6 +40,9 @@ internal class BoundServer(val engine: EmbeddedServer<*, *>, val port: Int, val
* preferred port can't be bound, retry on an OS-assigned free port (port 0). * preferred port can't be bound, retry on an OS-assigned free port (port 0).
* *
* The caller must read [BoundServer.port] instead of assuming [preferredPort]. * The caller must read [BoundServer.port] instead of assuming [preferredPort].
*
* `reuseAddress` lets a restarted app rebind its old port while connections from the previous
* run (e.g. the player's) are still in TIME_WAIT.
*/ */
internal suspend fun startServerWithPortFallback( internal suspend fun startServerWithPortFallback(
host: String, host: String,
@ -47,8 +53,25 @@ internal suspend fun startServerWithPortFallback(
val candidates = listOf(preferredPort, 0).distinct() val candidates = listOf(preferredPort, 0).distinct()
var lastFailure: Throwable? = null var lastFailure: Throwable? = null
// CIO reports a failed bind from its accept coroutine. Unhandled, that exception kills the
// process on Kotlin/Native (iOS) before the fallback below gets to run; the failure still
// reaches us through resolvedConnectors(), so it only needs to be contained here.
val swallowBindFailure = CoroutineExceptionHandler { _, _ -> }
for (candidate in candidates) { for (candidate in candidates) {
val engine = embeddedServer(factory = CIO, host = host, port = candidate, module = module) val engine = embeddedServer(
factory = CIO,
rootConfig = serverConfig {
parentCoroutineContext = swallowBindFailure
module { module() }
},
configure = {
connector {
this.host = host
this.port = candidate
}
reuseAddress = true
})
try { try {
engine.start(wait = false) engine.start(wait = false)
val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port } val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port }

View File

@ -23,13 +23,18 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.getPlatform
import org.spotube.spotube.modules.plugin.AudioPluginSource import org.spotube.spotube.modules.plugin.AudioPluginSource
import org.spotube.spotube.modules.settings.UserSettingsSource import org.spotube.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import kotlin.math.abs import kotlin.math.abs
import kotlin.time.Clock import kotlin.time.Clock
@ -111,7 +116,14 @@ class StreamingUrlRepository(
// read it, nor replace it with a stream playback didn't ask for. // read it, nor replace it with a stream playback didn't ask for.
val useUrlCache = request == null val useUrlCache = request == null
// A built-in audio plugin is always selected, so null means plugins are still loading.
// The player requests the restored queue's track right at startup; failing that request
// leaves the item permanently failed (AVPlayer), so wait for the plugin instead.
val audioPlugin = pluginManager.selectedAudioPlugin.value val audioPlugin = pluginManager.selectedAudioPlugin.value
?: withTimeoutOrNull(AUDIO_PLUGIN_LOAD_TIMEOUT) {
logger.i { "[Resolve $trackId] waiting for the audio plugin to load" }
pluginManager.selectedAudioPlugin.filterNotNull().first()
}
if (audioPlugin == null) { if (audioPlugin == null) {
logger.w { "No audio plugin selected while resolving stream for track $trackId" } logger.w { "No audio plugin selected while resolving stream for track $trackId" }
return null return null
@ -243,10 +255,12 @@ class StreamingUrlRepository(
logger.i { logger.i {
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " +
"(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; lowest wins):\n" + "(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
"lowest wins):\n" +
streams.joinToString("\n") { stream -> streams.joinToString("\n") { stream ->
val marker = if (stream === selected) " -> " else " " val marker = if (stream === selected) " -> " else " "
"$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 0 else 1}, " +
"${formatMismatchCount(stream, preferredFormat)}, " +
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
@ -254,6 +268,10 @@ class StreamingUrlRepository(
} }
when { when {
selected == null -> logger.w { "[Resolve $trackId] no stream selected" } selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
!selected.isPlayableOn(currentPlatform) -> logger.w {
"[Resolve $trackId] NO PLAYABLE STREAM on $currentPlatform; falling back to " +
"${selected.describeForLog()}, which the player will likely reject"
}
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
"got ${selected.codec}/${selected.container} (${selected.protocol})" "got ${selected.codec}/${selected.container} (${selected.protocol})"
@ -371,6 +389,8 @@ class StreamingUrlRepository(
* Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are * Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are
* resolved against the YouTube origin; progressive URLs are returned unchanged. * resolved against the YouTube origin; progressive URLs are returned unchanged.
*/ */
private val AUDIO_PLUGIN_LOAD_TIMEOUT = 30.seconds
internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String { internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String {
if (url.startsWith("http")) return url if (url.startsWith("http")) return url
return when (protocol) { return when (protocol) {
@ -379,12 +399,21 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
} }
} }
/** Selects the available stream closest to the user's preferred format and quality. */ internal val currentPlatform: PlatformType by lazy { getPlatform().type }
/**
* Selects the available stream closest to the user's preferred format and quality.
* Streams the [platform]'s player can decode always win over ones it can't, whatever the
* preferences say: e.g. opus/webm is unplayable on iOS.
*/
internal fun selectPreferredAudioStream( internal fun selectPreferredAudioStream(
streams: List<AudioStream>, streams: List<AudioStream>,
preferredFormat: AudioFormat, preferredFormat: AudioFormat,
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( preferredQuality: AudioQuality,
platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull(
compareBy<AudioStream> { stream -> compareBy<AudioStream> { stream ->
if (stream.isPlayableOn(platform)) 0 else 1
}.thenBy { stream ->
formatMismatchCount(stream, preferredFormat) formatMismatchCount(stream, preferredFormat)
}.thenBy { stream -> }.thenBy { stream ->
if (stream.matchesQualityType(preferredQuality)) 0 else 1 if (stream.matchesQualityType(preferredQuality)) 0 else 1
@ -395,6 +424,21 @@ internal fun selectPreferredAudioStream(
} }
) )
// AVPlayer has no WebM/Ogg/Matroska demuxer, so e.g. YouTube's opus/webm streams fail to load.
private val IOS_UNSUPPORTED_CONTAINERS = setOf("webm", "ogg", "oga", "mkv", "matroska")
/**
* Whether [platform]'s player can play this stream. VLC (desktop) and ExoPlayer (Android)
* handle everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
*/
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
isContainerPlayableOn(container, platform) &&
!(platform == PlatformType.IOS && protocol == StreamProtocol.DASH)
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
platform != PlatformType.IOS || container.lowercase() !in IOS_UNSUPPORTED_CONTAINERS
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1) (if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)

View File

@ -17,12 +17,13 @@
package org.spotube.spotube.core.tools.user_agents package org.spotube.spotube.core.tools.user_agents
import org.koin.core.component.inject
import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import org.spotube.spotube.PlatformType import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.paths.Paths
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess import io.ktor.http.isSuccess
@ -53,7 +54,8 @@ import kotlin.time.Clock
* [random] is intentionally synchronous and never blocks on the network: it serves * [random] is intentionally synchronous and never blocks on the network: it serves
* whatever is cached (even stale) and refreshes in the background when needed. * whatever is cached (even stale) and refreshes in the background when needed.
*/ */
object UserAgents { object UserAgents : KoinComponent {
private val httpClientFactory: HttpClientFactory by inject()
private const val SOURCE_URL = private const val SOURCE_URL =
"https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" "https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json"
private const val CACHE_FILE_NAME = "user_agents_cache.json" private const val CACHE_FILE_NAME = "user_agents_cache.json"
@ -111,14 +113,9 @@ object UserAgents {
private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/") private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/")
private suspend fun fetchRemote(): List<String> { private suspend fun fetchRemote(): List<String> {
val client = HttpClient(CIO) val res = httpClientFactory.default.get(SOURCE_URL)
try {
val res = client.get(SOURCE_URL)
if (!res.status.isSuccess()) error("Unexpected status ${res.status}") if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
} finally {
client.close()
}
} }
private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath() private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath()

View File

@ -40,7 +40,8 @@ class ZiplineDispatcher(
* size is ~1 MiB, which causes native stack overflow when compiling large * size is ~1 MiB, which causes native stack overflow when compiling large
* JS modules like kotlin-stdlib (~491 KB). * JS modules like kotlin-stdlib (~491 KB).
* *
* This expect/actual ensures the backing thread has a sufficiently large stack. * iOS secondary threads get only 512 KiB, so every platform must create its own
* large-stack thread. This expect/actual ensures the backing thread has one.
*/ */
expect fun createZiplineDispatcher(): ZiplineDispatcher expect fun createZiplineDispatcher(): ZiplineDispatcher

View File

@ -17,11 +17,12 @@
package org.spotube.spotube.core.zipline.host_apis package org.spotube.spotube.core.zipline.host_apis
import org.koin.core.component.inject
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.plugin_interfaces.host_apis.HttpClientAPI import org.spotube.plugin_interfaces.host_apis.HttpClientAPI
import org.spotube.plugin_interfaces.host_apis.HttpMethod import org.spotube.plugin_interfaces.host_apis.HttpMethod
import org.spotube.plugin_interfaces.host_apis.HttpResponse import org.spotube.plugin_interfaces.host_apis.HttpResponse
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import io.ktor.client.HttpClient
import io.ktor.client.request.request import io.ktor.client.request.request
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
@ -30,7 +31,8 @@ import org.koin.core.component.KoinComponent
class RealHttpClientAPI : HttpClientAPI, KoinComponent { class RealHttpClientAPI : HttpClientAPI, KoinComponent {
val logger by injectLogger<RealHttpClientAPI>() val logger by injectLogger<RealHttpClientAPI>()
val httpClient = HttpClient() private val httpClientFactory: HttpClientFactory by inject()
private val httpClient get() = httpClientFactory.default
override suspend fun request( override suspend fun request(
method: HttpMethod, method: HttpMethod,

View File

@ -17,11 +17,14 @@
package org.spotube.spotube.core.zipline.plugin_apis.lrclib package org.spotube.spotube.core.zipline.plugin_apis.lrclib
import org.koin.core.component.inject
import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.network.HttpJson
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.defaultRequest
@ -36,7 +39,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable @Serializable
data class LRCLibResponse( data class LRCLibResponse(
@ -47,14 +49,16 @@ data class LRCLibResponse(
val plainLyrics: String?, val plainLyrics: String?,
val syncedLyrics: String?) val syncedLyrics: String?)
class RealLRCLibLyricsAPI : LyricsAPI { class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent {
private val httpClient = HttpClient { private val httpClientFactory: HttpClientFactory by inject()
private val httpClient by lazy {
httpClientFactory.create {
install(Logging) { install(Logging) {
level = LogLevel.ALL level = LogLevel.ALL
logger = Logger.SIMPLE logger = Logger.SIMPLE
} }
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true }) json(HttpJson)
} }
defaultRequest { defaultRequest {
url("https://lrclib.net/api/get") url("https://lrclib.net/api/get")
@ -62,6 +66,7 @@ class RealLRCLibLyricsAPI : LyricsAPI {
userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin")
} }
} }
}
private suspend fun getLRCLibLyrics(track: MetadataTrack): LRCLibResponse? = private suspend fun getLRCLibLyrics(track: MetadataTrack): LRCLibResponse? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {

View File

@ -17,15 +17,15 @@
package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt
import org.koin.core.component.inject
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.tools.user_agents.UserAgents import org.spotube.spotube.core.tools.user_agents.UserAgents
import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.post import io.ktor.client.request.post
@ -54,15 +54,8 @@ class ISRCProviders : KoinComponent {
.lowercase() .lowercase()
} }
val client = HttpClient() { private val httpClientFactory: HttpClientFactory by inject()
install(ContentNegotiation) { private val client get() = httpClientFactory.json
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
private fun MetadataTrack.describe() = private fun MetadataTrack.describe() =
"id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " +

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.downloads package org.spotube.spotube.modules.downloads
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.paths.Paths
@ -26,7 +27,6 @@ import org.spotube.spotube.core.server.StreamingUrlRepository
import org.spotube.spotube.core.server.urlSummary import org.spotube.spotube.core.server.urlSummary
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.spotube.modules.settings.SettingsRepository import org.spotube.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.head import io.ktor.client.request.head
@ -88,7 +88,8 @@ sealed interface DownloadStatus {
class DownloadManager( class DownloadManager(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val paths: Paths, private val paths: Paths,
private val streamingUrlRepository: StreamingUrlRepository private val streamingUrlRepository: StreamingUrlRepository,
private val httpClientFactory: HttpClientFactory
) { ) {
companion object { companion object {
const val MAX_CONCURRENT_DOWNLOADS = 4 const val MAX_CONCURRENT_DOWNLOADS = 4
@ -101,7 +102,7 @@ class DownloadManager(
private val fileSystem = FileSystem.SYSTEM private val fileSystem = FileSystem.SYSTEM
private val queueMutex = Mutex() private val queueMutex = Mutex()
private val httpClient = HttpClient { private val httpClient = httpClientFactory.create {
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = 300_000 requestTimeoutMillis = 300_000
connectTimeoutMillis = 30_000 connectTimeoutMillis = 30_000

View File

@ -17,9 +17,9 @@
package org.spotube.spotube.modules.plugin package org.spotube.spotube.modules.plugin
import org.spotube.spotube.core.network.HttpClientFactory
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.headers import io.ktor.client.request.headers
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
@ -27,7 +27,6 @@ import io.ktor.http.append
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable @Serializable
data class GitHubRepoSearchResponse( data class GitHubRepoSearchResponse(
@ -64,12 +63,8 @@ data class GitHubAsset(
val name: String, val name: String,
@SerialName("browser_download_url") val browserDownloadUrl: String) @SerialName("browser_download_url") val browserDownloadUrl: String)
class GitHubPluginRepository { /** [httpClient] must have JSON content negotiation installed (see [HttpClientFactory.json]). */
private val httpClient = HttpClient { class GitHubPluginRepository(private val httpClient: HttpClient) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse { suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse {
return httpClient.get("https://api.github.com/search/repositories") { return httpClient.get("https://api.github.com/search/repositories") {
@ -113,8 +108,4 @@ class GitHubPluginRepository {
emptyList() emptyList()
} }
} }
fun close() {
httpClient.close()
}
} }

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.plugin package org.spotube.spotube.modules.plugin
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.spotube.core.db.Database import org.spotube.spotube.core.db.Database
import org.spotube.spotube.core.db.DatabaseKeys import org.spotube.spotube.core.db.DatabaseKeys
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
@ -24,7 +25,6 @@ import org.spotube.spotube.core.paths.Paths
import org.spotube.spotube.core.zipline.BuiltInPluginService import org.spotube.spotube.core.zipline.BuiltInPluginService
import org.spotube.spotube.core.zipline.PluginService import org.spotube.spotube.core.zipline.PluginService
import org.spotube.spotube.core.zipline.ZiplinePluginService import org.spotube.spotube.core.zipline.ZiplinePluginService
import io.ktor.client.HttpClient
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsChannel
import io.ktor.utils.io.readRemaining import io.ktor.utils.io.readRemaining
@ -113,7 +113,8 @@ interface ScrobblePluginSource {
class PluginManager( class PluginManager(
val database: Database, val database: Database,
val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { val paths: Paths,
private val httpClientFactory: HttpClientFactory) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
private val logger by injectLogger<PluginManager>() private val logger by injectLogger<PluginManager>()
// plugin.json may carry fields newer than this app knows about (e.g. `entryPoint`). // plugin.json may carry fields newer than this app knows about (e.g. `entryPoint`).
@ -124,7 +125,7 @@ class PluginManager(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler)
private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath()
val pluginsDirPath: Path get() = pluginsDir val pluginsDirPath: Path get() = pluginsDir
private val httpClient = HttpClient() private val httpClient get() = httpClientFactory.default
enum class InstallPromptKind { enum class InstallPromptKind {
INSTALL, INSTALL,

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.plugin package org.spotube.spotube.modules.plugin
import org.spotube.spotube.core.network.HttpClientFactory
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
@ -141,10 +142,11 @@ private data class PluginScreenState(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class PluginViewModel( class PluginViewModel(
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
private val webViewController: WebViewController) : ViewModel(), KoinComponent { private val webViewController: WebViewController,
httpClientFactory: HttpClientFactory) : ViewModel(), KoinComponent {
private val logger by injectLogger<PluginViewModel>() private val logger by injectLogger<PluginViewModel>()
private val gitHubRepo = GitHubPluginRepository() private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json)
private val _screenState = MutableStateFlow(PluginScreenState()) private val _screenState = MutableStateFlow(PluginScreenState())
private val _allRepos = mutableListOf<GitHubRepo>() private val _allRepos = mutableListOf<GitHubRepo>()
@ -658,8 +660,4 @@ class PluginViewModel(
} }
} }
override fun onCleared() {
gitHubRepo.close()
super.onCleared()
}
} }

View File

@ -17,6 +17,9 @@
package org.spotube.spotube.modules.settings package org.spotube.spotube.modules.settings
import org.spotube.spotube.core.server.isContainerPlayableOn
import org.spotube.spotube.core.server.currentPlatform
import org.spotube.spotube.PlatformType
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -42,15 +45,7 @@ data class UserSettings(
val palette: MillenniumPalette = MillenniumPalette.Blue, val palette: MillenniumPalette = MillenniumPalette.Blue,
// Playback // Playback
val streamingMusicFormat: AudioFormat = AudioFormat( val streamingMusicFormat: AudioFormat = defaultStreamingMusicFormat(),
codec = "opus",
container = "webm",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000))
),
val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000),
val enableMusicCaching: Boolean = true, val enableMusicCaching: Boolean = true,
val cacheFolder: String? = null, val cacheFolder: String? = null,
@ -111,3 +106,24 @@ data class JamBroker(
val clientIdPrefix: String = "spotube", val clientIdPrefix: String = "spotube",
val keepAliveSeconds: Int = 30, val keepAliveSeconds: Int = 30,
val connectionTimeoutSeconds: Int = 10) val connectionTimeoutSeconds: Int = 10)
/**
* opus/webm everywhere except iOS, whose AVPlayer can't open WebM/Ogg, so it streams AAC.
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
*/
internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat {
val ios = platform == PlatformType.IOS
return AudioFormat(
codec = if (ios) "aac" else "opus",
container = if (ios) "mp4" else "webm",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000)))
}
/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */
internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings =
if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this
else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform))

View File

@ -54,7 +54,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs -> override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY] val json = prefs[SETTINGS_KEY]
if (json != null) { if (json != null) {
settingsJson.decodeFromString<UserSettings>(json as String) settingsJson.decodeFromString<UserSettings>(json as String).withPlayableStreamingFormat()
} else { } else {
UserSettings() // Default value UserSettings() // Default value
} }
@ -73,6 +73,6 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
suspend fun getCurrentSettings(): UserSettings { suspend fun getCurrentSettings(): UserSettings {
val preferences = database.settingsDataStore.data.first() val preferences = database.settingsDataStore.data.first()
val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings()
return settingsJson.decodeFromString<UserSettings>(storedJson) return settingsJson.decodeFromString<UserSettings>(storedJson).withPlayableStreamingFormat()
} }
} }

View File

@ -18,6 +18,8 @@
package org.spotube.spotube.modules.settings.sections package org.spotube.spotube.modules.settings.sections
import org.spotube.spotube.core.server.isContainerPlayableOn
import org.spotube.spotube.core.server.currentPlatform
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
@ -56,6 +58,7 @@ private val standardLossyQualities = listOf(
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000)) AudioQuality.Lossy(bitrate = 256_000))
// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg).
internal val streamingFormatPresets = listOf( internal val streamingFormatPresets = listOf(
AudioFormat( AudioFormat(
codec = "opus", codec = "opus",
@ -68,7 +71,8 @@ internal val streamingFormatPresets = listOf(
AudioFormat( AudioFormat(
codec = "vorbis", codec = "vorbis",
container = "ogg", container = "ogg",
qualities = standardLossyQualities)) qualities = standardLossyQualities)
).filter { isContainerPlayableOn(it.container, currentPlatform) }
internal val downloadFormatPresets = listOf( internal val downloadFormatPresets = listOf(
AudioFormat( AudioFormat(

View File

@ -17,13 +17,11 @@
package org.spotube.spotube.modules.update package org.spotube.spotube.modules.update
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import org.spotube.spotube.AppBuildInfo import org.spotube.spotube.AppBuildInfo
import org.spotube.spotube.modules.settings.SettingsRepository import org.spotube.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
@ -37,7 +35,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.swiftzer.semver.SemVer import net.swiftzer.semver.SemVer
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -52,14 +49,11 @@ data class AvailableAppUpdate(
* process, independent of any screen's lifecycle. * process, independent of any screen's lifecycle.
*/ */
class AppUpdateService( class AppUpdateService(
private val settingsRepository: SettingsRepository) : KoinComponent, AutoCloseable { private val settingsRepository: SettingsRepository,
httpClientFactory: HttpClientFactory) : KoinComponent {
private val logger = Logger.withTag("AppUpdateService") private val logger = Logger.withTag("AppUpdateService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val httpClient = HttpClient(CIO) { private val httpClient = httpClientFactory.json
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null) private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null)
val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow() val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow()
@ -117,10 +111,6 @@ class AppUpdateService(
private fun parseVersion(version: String): SemVer? = runCatching { private fun parseVersion(version: String): SemVer? = runCatching {
SemVer.parse(version) SemVer.parse(version)
}.getOrNull() }.getOrNull()
override fun close() {
httpClient.close()
}
} }
@Serializable @Serializable

View File

@ -8,6 +8,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.modules.settings.UserSettings import org.spotube.spotube.modules.settings.UserSettings
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
@ -130,6 +132,22 @@ class StreamingUrlRepositoryBehaviorTest {
assertNull(info) assertNull(info)
} }
@Test
fun `waits for an audio plugin that is still loading`() = runTest {
val h = Harness()
val loader = launch {
delay(5_000)
h.selectPlugin("pluginA").audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
}
val info = h.repository.resolveStreamInfo(track("t1"))
loader.join()
assertEquals("https://example.com/a.opus", info?.url)
}
@Test @Test
fun `returns null when the plugin yields no sources`() = runTest { fun `returns null when the plugin yields no sources`() = runTest {
val h = Harness() val h = Harness()

View File

@ -1,5 +1,6 @@
package org.spotube.spotube.core.server package org.spotube.spotube.core.server
import org.spotube.spotube.PlatformType
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
@ -13,6 +14,7 @@ import kotlin.test.assertSame
* Tests for the stream preference selection used by [StreamingUrlRepository]. * Tests for the stream preference selection used by [StreamingUrlRepository].
* *
* Ordering contract (best first): * Ordering contract (best first):
* 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH).
* 1. Fewest codec/container mismatches against the preferred format. * 1. Fewest codec/container mismatches against the preferred format.
* 2. Matching quality family (lossy vs lossless). * 2. Matching quality family (lossy vs lossless).
* 3. Closest bitrate / sample rate. * 3. Closest bitrate / sample rate.
@ -54,7 +56,8 @@ class StreamingUrlRepositoryTest {
private fun select( private fun select(
streams: List<AudioStream>, streams: List<AudioStream>,
format: AudioFormat = opusWebm(), format: AudioFormat = opusWebm(),
quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality) quality: AudioQuality = AudioQuality.Lossy(256_000),
platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform)
// ---------- empty / single ---------- // ---------- empty / single ----------
@ -403,4 +406,34 @@ class StreamingUrlRepositoryTest {
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH)) assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH))
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
} }
// ---------- device playability ----------
@Test
fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() {
val opus = lossy("opus-160", 160_000)
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS))
}
@Test
fun `on iOS dash manifests are skipped in favour of progressive streams`() {
val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH)
val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS))
}
@Test
fun `on iOS an unplayable stream is still returned when nothing else exists`() {
val opus = lossy("opus-160", 160_000)
assertSame(opus, select(listOf(opus), platform = PlatformType.IOS))
}
@Test
fun `off iOS the preferred opus webm stream still wins`() {
val opus = lossy("opus-160", 160_000)
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android))
}
} }

View File

@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.modules.settings
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
import org.spotube.spotube.PlatformType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
class StreamingFormatDefaultsTest {
private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
@Test
fun `ios defaults to aac in mp4`() {
val format = defaultStreamingMusicFormat(PlatformType.IOS)
assertEquals("aac" to "mp4", format.codec to format.container)
}
@Test
fun `other platforms keep opus in webm`() {
val format = defaultStreamingMusicFormat(PlatformType.Android)
assertEquals("opus" to "webm", format.codec to format.container)
}
@Test
fun `saved opus webm is moved to aac on ios`() {
val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS)
assertEquals("mp4", migrated.streamingMusicFormat.container)
}
@Test
fun `saved opus webm is kept off ios`() {
val settings = UserSettings(streamingMusicFormat = opusWebm)
assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux))
}
}

View File

@ -27,6 +27,7 @@ import androidx.compose.ui.window.ComposeUIViewController
import com.yushosei.newpipe.extractor.NewPipe import com.yushosei.newpipe.extractor.NewPipe
import com.yushosei.newpipe.util.DefaultDownloaderImpl import com.yushosei.newpipe.util.DefaultDownloaderImpl
import org.spotube.spotube.core.di.initKoin import org.spotube.spotube.core.di.initKoin
import org.spotube.spotube.core.webview.SafariUserAgent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -35,6 +36,7 @@ import org.koin.core.component.inject
fun MainViewController() = ComposeUIViewController(configure = { fun MainViewController() = ComposeUIViewController(configure = {
initKoin() initKoin()
SafariUserAgent.prime()
}) { InitNewPipe { App() } } }) { InitNewPipe { App() } }
@Composable @Composable

View File

@ -77,9 +77,9 @@ actual class NewPipeService {
.map { stream -> .map { stream ->
org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy( org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy(
url = stream.content, url = stream.content,
codec = stream.codec ?: "unknown", codec = normalizeCodec(stream.codec ?: "unknown"),
bitrate = stream.bitrate, bitrate = stream.bitrate,
container = stream.format?.suffix ?: "unknown") container = normalizeContainer(stream.format?.suffix ?: "unknown"))
}, },
videoStreams = listOf(), // Not yet supported videoStreams = listOf(), // Not yet supported
id = id id = id

View File

@ -27,6 +27,8 @@ import platform.WebKit.WKWebsiteDataTypeCookies
import platform.WebKit.WKWebsiteDataTypeLocalStorage import platform.WebKit.WKWebsiteDataTypeLocalStorage
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
// Applied to the WKWebView itself, so it covers redirects and XHR too, not just the first request.
SafariUserAgent.value?.let { webView.webSettings.customUserAgentString = it }
} }
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)

View File

@ -0,0 +1,68 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.webview
import co.touchlab.kermit.Logger
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.readValue
import platform.CoreGraphics.CGRectZero
import platform.UIKit.UIDevice
import platform.UIKit.UIUserInterfaceIdiomPad
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
/**
* Mobile Safari's exact User-Agent on this device.
*
* WKWebView's default UA ends in `Mobile/15E148` without Safari's `Version/x … Safari/604.1`,
* which sites like accounts.spotify.com treat as an embedded browser ("Oops, something went
* wrong"). Hand-building the string isn't safe either: Safari freezes the OS part (iOS 26
* reports `iPhone OS 18_7`). So WebKit builds it: a probe WKWebView gets Safari's application
* name and reports `navigator.userAgent`, which is cached for every web view.
*/
internal object SafariUserAgent {
var value: String? = null
private set
// Kept alive until its async evaluation calls back.
private var probe: WKWebView? = null
/** Must run on the main thread (WebKit); call once at startup. */
@OptIn(ExperimentalForeignApi::class)
fun prime() {
if (value != null || probe != null) return
val config = WKWebViewConfiguration().apply { applicationNameForUserAgent = safariApplicationName() }
probe = WKWebView(frame = CGRectZero.readValue(), configuration = config).also { webView ->
webView.evaluateJavaScript("navigator.userAgent") { result, _ ->
value = result as? String
probe = null
Logger.withTag("SafariUserAgent").i { "WebView User-Agent: $value" }
}
}
}
// What Safari appends after WebKit's base UA; iPad Safari sends the desktop-class form.
private fun safariApplicationName(): String {
val version = UIDevice.currentDevice.systemVersion.split('.').take(2).joinToString(".")
return if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
"Version/$version Safari/605.1.15"
} else {
"Version/$version Mobile/15E148 Safari/604.1"
}
}
}

View File

@ -17,17 +17,32 @@
package org.spotube.spotube.core.zipline package org.spotube.spotube.core.zipline
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.runBlocking
import platform.Foundation.NSThread
import kotlin.coroutines.ContinuationInterceptor
// Same as JVM/Android. Zipline lets QuickJS recurse up to ~6 MiB of native stack, but
// iOS gives secondary threads (incl. newSingleThreadContext's) only 512 KiB, so loading
// a plugin overflowed into the guard page and crashed the app with SIGBUS.
private const val ZIPLINE_THREAD_STACK_SIZE: ULong = 8_388_608u // 8 MiB
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
actual fun createZiplineDispatcher(): ZiplineDispatcher { actual fun createZiplineDispatcher(): ZiplineDispatcher {
// On iOS/native, thread stack sizes are typically 8 MiB by default, val dispatcher = CompletableDeferred<CoroutineDispatcher>()
// so newSingleThreadContext is sufficient. val stopped = CompletableDeferred<Unit>()
val ctx = newSingleThreadContext("Zipline") // NSThread is the only way to choose a thread's stack size here; it hosts an event
return ZiplineDispatcher(ctx) { // loop (runBlocking) whose dispatcher accepts work from any thread.
ctx.close() val thread = NSThread {
runBlocking {
dispatcher.complete(coroutineContext[ContinuationInterceptor] as CoroutineDispatcher)
stopped.await()
}
}
thread.name = "Zipline"
thread.stackSize = ZIPLINE_THREAD_STACK_SIZE
thread.start()
return ZiplineDispatcher(runBlocking { dispatcher.await() }) {
stopped.complete(Unit)
} }
} }

View File

@ -28,7 +28,7 @@ appdirs = "1.5.0"
cache4k = "0.14.0" cache4k = "0.14.0"
carApp = "1.7.0" carApp = "1.7.0"
composeHotReload = "1.2.0" composeHotReload = "1.2.0"
composeMultiplatform = "1.11.1" composeMultiplatform = "1.12.2"
coil = "3.5.0" coil = "3.5.0"
composeShimmer = "1.5.0" composeShimmer = "1.5.0"
desugar_jdk_libs = "2.1.5" desugar_jdk_libs = "2.1.5"
@ -37,7 +37,7 @@ jna = "5.18.1"
junit = "4.13.2" junit = "4.13.2"
kermit = "2.1.0" kermit = "2.1.0"
kmpZip = "1.0.0" kmpZip = "1.0.0"
kotlin = "2.3.21" kotlin = "2.4.21"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.9.1" kotlinx-io = "0.9.1"
kotlinx-serialization-json = "1.11.0" kotlinx-serialization-json = "1.11.0"
@ -52,7 +52,7 @@ nucleusNucleusApplication = "2.5.18"
semver = "2.1.0" semver = "2.1.0"
vlcj = "4.12.1" vlcj = "4.12.1"
vlcjNative = "4.12.0" vlcjNative = "4.12.0"
ziplineVersion = "1.27.0" ziplineVersion = "1.28.0"
ktor = "3.5.1" ktor = "3.5.1"
dnssdkt = "1.1.0" dnssdkt = "1.1.0"
runner = "1.7.0" runner = "1.7.0"
@ -68,7 +68,7 @@ feather-icons = "1.1.1"
kotlinx-datetime = "0.8.0" kotlinx-datetime = "0.8.0"
media3 = "1.10.1" media3 = "1.10.1"
androidx-datastore = "1.2.1" androidx-datastore = "1.2.1"
ksp = "2.3.5" ksp = "2.3.12"
spotube-gradle = "0.1.2" spotube-gradle = "0.1.2"
cryptography = "0.6.0" cryptography = "0.6.0"
reorderable = "3.1.0" reorderable = "3.1.0"
@ -76,7 +76,7 @@ vlcjBundler = "0.2.0"
vanniktechMavenPublish = "0.37.0" vanniktechMavenPublish = "0.37.0"
uniffi = "0.3.7" uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0" kotlinx-atomicfu = "0.33.0"
mokkery = "3.3.0" mokkery = "3.5.1"
jmustache = "1.16" jmustache = "1.16"
[libraries] [libraries]

View File

@ -14,6 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import SwiftUI import SwiftUI
import ComposeApp
@main @main
struct iOSApp: App { struct iOSApp: App {