Compare commits

..

3 Commits

23 changed files with 1775 additions and 295 deletions

View File

@ -3,6 +3,7 @@
"mcp": { "mcp": {
"compose-hot-reload": { "compose-hot-reload": {
"enabled": true, "enabled": true,
"disabled": false,
"type": "local", "type": "local",
"command": [ "command": [
"./gradlew", "./gradlew",
@ -11,6 +12,20 @@
"--console=plain", "--console=plain",
"hotMcpServer" "hotMcpServer"
] ]
},
"android-studio-mcp": {
"type": "local",
"environment": {
"IJ_MCP_SERVER_PORT": "64342"
},
"enabled": true,
"disabled": false,
"command": [
"/opt/android-studio/jbr/bin/java",
"-classpath",
"/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/mcpserver.jar:/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/io.modelcontextprotocol.kotlin.sdk.jar:/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/io.github.oshai.kotlin.logging.jvm.jar:/opt/android-studio/lib/util-8.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.coroutines.core.jar:/opt/android-studio/lib/intellij.libraries.ktor.client.cio.jar:/opt/android-studio/lib/intellij.libraries.ktor.client.jar:/opt/android-studio/lib/intellij.libraries.ktor.network.tls.jar:/opt/android-studio/lib/intellij.libraries.ktor.io.jar:/opt/android-studio/lib/intellij.libraries.ktor.utils.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.io.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.serialization.core.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.serialization.json.jar",
"com.intellij.mcpserver.stdio.McpStdioRunnerKt"
]
} }
} }
} }

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"gradle.autoDetect": "off",
"java.gradle.buildServer.enabled": "off"
}

View File

@ -57,3 +57,18 @@
- **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease. - **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease.
- **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets. - **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets.
- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached. - `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached.
## OpenCode Agent MCP Execution Directives
### 1. Code Discovery & Context Search
- Use `android-studio-mcp` search/symbol tools to locate declarations before making edits across `:composeApp`, `:plugin_interfaces`, or `:js_plugin_example`[cite: 1, 2].
- Keep `gradle/libs.versions.toml` as the single source of truth for dependencies and version updates.
### 2. UI Architecture Constraints
- **Strict ViewModel Separation:** UI composables in `commonMain` must only collect `StateFlow<UiState>` and emit event callbacks. Do NOT write business logic, filtering, or `mutableStateListOf` directly inside `@Composable` functions.
- **Adaptive Components:** Use `AdaptiveDropdownBottomSheet` or `AdaptiveDialogBottomSheet` inside `commonMain` for adaptive layouts[cite: 2]. Do NOT create platform-specific `expect/actual` variants unless platform-native APIs (like windowing or native scrollbars) are explicitly required[cite: 2].
### 3. Verification & Diagnostic Loops
- **Post-Edit Verification:** After editing Kotlin code, validate compilation by running `./gradlew :composeApp:check` or `./gradlew :composeApp:assembleDebug` via MCP[cite: 2].
- **Dispatcher Integrity:** Never remove or bypass `SwingMainDispatcherFactory` in `composeApp/src/jvmMain/.../core/coroutines/`[cite: 2]. It is required to prevent `compose-webview` from hijacking `Dispatchers.Main` away from the Swing EDT[cite: 2].
- **Live Preview Iteration:** Use `compose-hot-reload` to evaluate UI layout changes headlessly without forcing full Gradle rebuilds.

View File

@ -182,10 +182,20 @@
<string name="settings_jam_broker_username">Username (optional)</string> <string name="settings_jam_broker_username">Username (optional)</string>
<string name="settings_jam_broker_password">Password (optional)</string> <string name="settings_jam_broker_password">Password (optional)</string>
<string name="settings_jam_broker_client_id">Client ID prefix</string> <string name="settings_jam_broker_client_id">Client ID prefix</string>
<string name="settings_jam_broker_client_id_subtitle">Used to identify this Spotube instance</string>
<string name="settings_jam_broker_tls_subtitle">Encrypt the broker connection</string>
<string name="settings_jam_broker_diagnostics_subtitle">Check the current broker settings before starting a room.</string>
<string name="settings_jam_broker_test">Test connection</string> <string name="settings_jam_broker_test">Test connection</string>
<string name="settings_jam_broker_testing">Testing…</string> <string name="settings_jam_broker_testing">Testing…</string>
<string name="settings_jam_broker_test_ok">Connected in %1$d ms</string> <string name="settings_jam_broker_test_ok">Connected in %1$d ms</string>
<string name="settings_jam_broker_test_fail">Failed: %1$s</string> <string name="settings_jam_broker_test_fail">Failed: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Placeholder broker — configure your own to self-host</string> <string name="settings_jam_broker_placeholder_note">Placeholder broker — configure your own to self-host</string>
</resources> <string name="settings_jam_section_connection">Connection</string>
<string name="settings_jam_section_credentials">Credentials</string>
<string name="settings_jam_section_identity">Identity</string>
<string name="settings_jam_section_diagnostics">Diagnostics</string>
<string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string>
<string name="player_track_details">Track details</string>
</resources>

View File

@ -1,26 +0,0 @@
/*
* 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 dev.krtirtho.spotube
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}

View File

@ -17,40 +17,10 @@
package dev.krtirtho.spotube.core.audioplayer package dev.krtirtho.spotube.core.audioplayer
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration import kotlin.time.Duration
data class MediaItem(
val title: String,
val artist: String,
val album: String,
val duration: Duration,
val coverURL: String,
val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
) {
fun toDebugString(): String {
return "MediaItem(title='$title', artist='$artist', album='$album', duration=$duration, coverURL='$coverURL', url='$url', protocol=$protocol)"
}
}
enum class LoopState {
NONE, ONE, ALL;
fun next(): LoopState {
return when (this) {
NONE -> ONE
ONE -> ALL
ALL -> NONE
}
}
}
enum class PlayerState {
IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED
}
interface AudioPlayerInterface { interface AudioPlayerInterface {
// // Playback // // Playback

View File

@ -21,6 +21,33 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.Duration
data class MediaItem(
val title: String,
val artist: String,
val album: String,
val duration: Duration,
val coverURL: String,
val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
)
enum class LoopState {
NONE, ONE, ALL;
fun next(): LoopState {
return when (this) {
NONE -> ONE
ONE -> ALL
ALL -> NONE
}
}
}
enum class PlayerState {
IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED
}
@Serializable @Serializable
sealed interface QueueEntry { sealed interface QueueEntry {

View File

@ -74,7 +74,9 @@ import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
import org.koin.core.module.Module import org.koin.core.module.Module
@ -96,6 +98,7 @@ val sharedModules = module {
// Shell // Shell
viewModelOf(::AppShellViewModel) viewModelOf(::AppShellViewModel)
viewModelOf(::PlayerOptionsViewModel)
viewModelOf(::PlayerQueueContentViewModel) viewModelOf(::PlayerQueueContentViewModel)
viewModelOf(::AlternativeTrackContentViewModel) viewModelOf(::AlternativeTrackContentViewModel)
@ -125,6 +128,7 @@ val sharedModules = module {
// Settings // Settings
singleOf(::SettingsRepository) singleOf(::SettingsRepository)
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() } viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel)
// Downloads // Downloads
singleOf(::DownloadManager) singleOf(::DownloadManager)

View File

@ -33,6 +33,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
import dev.krtirtho.spotube.modules.search.SearchScreen import dev.krtirtho.spotube.modules.search.SearchScreen
import dev.krtirtho.spotube.modules.settings.SettingsScreen import dev.krtirtho.spotube.modules.settings.SettingsScreen
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
@ -87,6 +88,9 @@ sealed interface Routes : NavKey {
@Serializable @Serializable
data object Jam : Routes data object Jam : Routes
@Serializable
data object JamSettings : Routes
} }
@OptIn(KoinExperimentalAPI::class) @OptIn(KoinExperimentalAPI::class)
@ -171,5 +175,7 @@ val navigationModule = module {
navigation<Routes.Jam> { navigation<Routes.Jam> {
JamScreen(navigationCommands = get()) JamScreen(navigationCommands = get())
} }
navigation<Routes.JamSettings> {
JamSettingsScreen(viewModel = koinViewModel())
}
} }

View File

@ -53,6 +53,7 @@ import okio.SYSTEM
import okio.buffer import okio.buffer
import okio.use import okio.use
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import kotlin.time.Duration.Companion.milliseconds
internal class StreamProxy( internal class StreamProxy(
private val httpClient: HttpClient, private val httpClient: HttpClient,
@ -114,7 +115,7 @@ internal class StreamProxy(
logger.d { "Track $trackId is being cached, waiting for cache" } logger.d { "Track $trackId is being cached, waiting for cache" }
var waited = 0 var waited = 0
while (waited < 30) { while (waited < 30) {
delay(200) delay(200.milliseconds)
waited++ waited++
val entry = cacheManager.findCachedEntry(trackId) val entry = cacheManager.findCachedEntry(trackId)
if (entry != null) { if (entry != null) {
@ -245,7 +246,7 @@ internal class StreamProxy(
call.respond(object : OutgoingContent.WriteChannelContent() { call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = HttpStatusCode.OK override val status: HttpStatusCode = HttpStatusCode.OK
override val contentType: ContentType = contentType override val contentType: ContentType = contentType
override val contentLength: Long? = rewrittenManifest.encodeToByteArray().size.toLong() override val contentLength: Long = rewrittenManifest.encodeToByteArray().size.toLong()
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
channel.writeFully(rewrittenManifest.encodeToByteArray()) channel.writeFully(rewrittenManifest.encodeToByteArray())
@ -305,7 +306,7 @@ internal class StreamProxy(
var currentUrl = streamUrl var currentUrl = streamUrl
var attemptedRefresh = false var attemptedRefresh = false
var upstream: HttpResponse? = null var upstream: HttpResponse?
while (true) { while (true) {
val response = runCatching { val response = runCatching {

View File

@ -18,15 +18,20 @@
package dev.krtirtho.spotube.core.server package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import kotlin.math.abs
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -36,6 +41,10 @@ data class CachedStreamUrl(
val expiresAtMs: Long, val expiresAtMs: Long,
val container: String, val container: String,
val codec: String, val codec: String,
val source: AudioSource.Streamed? = null,
val selectedStream: AudioStream? = null,
val preferredFormat: AudioFormat,
val preferredQuality: AudioQuality,
) )
data class StreamInfo( data class StreamInfo(
@ -45,11 +54,11 @@ data class StreamInfo(
val container: String, val container: String,
) )
class StreamingUrlRepository( class StreamingUrlRepository(
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: MatchedTracksRepository, private val matchedTracksRepository: MatchedTracksRepository,
private val settingsRepository: SettingsRepository,
) : KoinComponent { ) : KoinComponent {
companion object { companion object {
@ -62,15 +71,6 @@ class StreamingUrlRepository(
private val alternativesCacheMutex = Mutex() private val alternativesCacheMutex = Mutex()
private val alternativesCache = mutableMapOf<String, List<AudioSource>>() private val alternativesCache = mutableMapOf<String, List<AudioSource>>()
private fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String {
if (url.startsWith("http")) return url
val base = when (protocol) {
StreamProtocol.DASH, StreamProtocol.HLS -> "https://www.youtube.com"
StreamProtocol.PROGRESSIVE -> return url
}
return "$base$url"
}
suspend fun resolveStreamInfo( suspend fun resolveStreamInfo(
trackId: String, trackId: String,
forceRefresh: Boolean = false forceRefresh: Boolean = false
@ -91,13 +91,16 @@ class StreamingUrlRepository(
forceRefresh: Boolean = false forceRefresh: Boolean = false
): StreamInfo? { ): StreamInfo? {
val trackId = track.id val trackId = track.id
val settings = settingsRepository.userSettings.value
val preferredFormat = settings.streamingMusicFormat
val preferredQuality = settings.streamingMusicQuality
if (!forceRefresh) { if (!forceRefresh) {
getCachedStreamUrl(trackId)?.let { cached -> getCachedStreamUrl(trackId, preferredFormat, preferredQuality)?.let { cached ->
logger.v { "Using cached stream URL for track $trackId" } logger.v { "Using cached stream URL for track $trackId" }
return cached return cached
} }
} }
val audioPlugin = pluginManager.selectedAudioPlugin.value val audioPlugin = pluginManager.selectedAudioPlugin.value
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" }
@ -120,16 +123,14 @@ class StreamingUrlRepository(
if (stream != null) { if (stream != null) {
logger.d { "Resolved stream for track $trackId using cached source" } logger.d { "Resolved stream for track $trackId using cached source" }
val resolvedStream = stream.streams.firstOrNull() val resolvedStream = selectPreferredAudioStream(
if (resolvedStream != null) { stream.streams,
val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol) preferredFormat,
val info = StreamInfo( preferredQuality,
url,
resolvedStream.protocol,
resolvedStream.codec,
resolvedStream.container
) )
cacheStreamInfo(trackId, info) if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
return info return info
} }
} }
@ -182,44 +183,65 @@ class StreamingUrlRepository(
} }
logger.d { "Resolved stream for track $trackId via track lookup" } logger.d { "Resolved stream for track $trackId via track lookup" }
val resolvedStream = stream.streams.firstOrNull() val resolvedStream = selectPreferredAudioStream(
if (resolvedStream != null) { stream.streams,
val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol) preferredFormat,
val info = StreamInfo( preferredQuality,
url,
resolvedStream.protocol,
resolvedStream.codec,
resolvedStream.container
) )
cacheStreamInfo(trackId, info) if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
return info return info
} }
return null return null
} }
suspend fun getCachedStreamUrl(trackId: String): StreamInfo? { private fun AudioStream.toStreamInfo() = StreamInfo(
url = normalizeManifestUrl(url, protocol),
protocol = protocol,
codec = codec,
container = container,
)
private suspend fun getCachedStreamUrl(
trackId: String,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
): StreamInfo? {
val now = Clock.System.now().toEpochMilliseconds() val now = Clock.System.now().toEpochMilliseconds()
return streamUrlCacheMutex.withLock { return streamUrlCacheMutex.withLock {
val cached = streamUrlCache[trackId] ?: return@withLock null val cached = streamUrlCache[trackId] ?: return@withLock null
if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) {
return@withLock null
}
if (cached.expiresAtMs <= now) { if (cached.expiresAtMs <= now) {
streamUrlCache.remove(trackId)
logger.v { "Cached stream URL expired for track $trackId" } logger.v { "Cached stream URL expired for track $trackId" }
return@withLock null return@withLock null
} }
StreamInfo(cached.url, cached.protocol, cached.container, cached.codec) StreamInfo(cached.url, cached.protocol, cached.codec, cached.container)
} }
} }
suspend fun cacheStreamInfo(trackId: String, info: StreamInfo) { private suspend fun cacheStreamInfo(
trackId: String,
info: StreamInfo,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
source: AudioSource.Streamed,
selectedStream: AudioStream,
) {
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS
streamUrlCacheMutex.withLock { streamUrlCacheMutex.withLock {
streamUrlCache[trackId] = streamUrlCache[trackId] = CachedStreamUrl(
CachedStreamUrl(
url = info.url, url = info.url,
protocol = info.protocol, protocol = info.protocol,
expiresAtMs = expiresAtMs, expiresAtMs = expiresAtMs,
container = info.container, container = info.container,
codec = info.codec codec = info.codec,
source = source,
selectedStream = selectedStream,
preferredFormat = preferredFormat,
preferredQuality = preferredQuality,
) )
} }
} }
@ -232,6 +254,7 @@ class StreamingUrlRepository(
logger.v { "Invalidated cached stream URL for track $trackId" } logger.v { "Invalidated cached stream URL for track $trackId" }
} }
} }
// Stream metadata is part of the same URL-cache entry and is removed with it.
} }
suspend fun getCachedAlternatives(trackId: String): List<AudioSource>? { suspend fun getCachedAlternatives(trackId: String): List<AudioSource>? {
@ -247,9 +270,77 @@ class StreamingUrlRepository(
} }
} }
/** Returns stream details from the existing playback URL cache; never fetches them. */
suspend fun getCachedStreamUrlEntry(trackId: String): CachedStreamUrl? {
val now = Clock.System.now().toEpochMilliseconds()
return streamUrlCacheMutex.withLock {
val entry = streamUrlCache[trackId] ?: return@withLock null
if (entry.expiresAtMs <= now) {
logger.v { "Returning expired stream details for track $trackId" }
}
entry
}
}
suspend fun invalidateCachedAlternatives(trackId: String) { suspend fun invalidateCachedAlternatives(trackId: String) {
alternativesCacheMutex.withLock { alternativesCacheMutex.withLock {
alternativesCache.remove(trackId) alternativesCache.remove(trackId)
} }
} }
}
/**
* Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are
* resolved against the YouTube origin; progressive URLs are returned unchanged.
*/
internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String {
if (url.startsWith("http")) return url
return when (protocol) {
StreamProtocol.DASH, StreamProtocol.HLS -> "https://www.youtube.com$url"
StreamProtocol.PROGRESSIVE -> url
}
}
/** Selects the available stream closest to the user's preferred format and quality. */
internal fun selectPreferredAudioStream(
streams: List<AudioStream>,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
): AudioStream? = streams.minWithOrNull(
compareBy<AudioStream> { stream ->
formatMismatchCount(stream, preferredFormat)
}.thenBy { stream ->
if (stream.matchesQualityType(preferredQuality)) 0 else 1
}.thenBy { stream ->
stream.qualityDistance(preferredQuality)
}.thenBy { stream ->
stream.channelDistance(preferredQuality)
}
)
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)
private fun AudioStream.matchesQualityType(preferredQuality: AudioQuality): Boolean = when (this) {
is AudioStream.Lossy -> preferredQuality is AudioQuality.Lossy
is AudioStream.Lossless -> preferredQuality is AudioQuality.Lossless
}
private fun AudioStream.qualityDistance(preferredQuality: AudioQuality): Long = when {
this is AudioStream.Lossy && preferredQuality is AudioQuality.Lossy ->
abs(bitrate.toLong() - preferredQuality.bitrate.toLong())
this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless ->
abs(sampleRate.toLong() - preferredQuality.sampleRate.toLong())
else -> Long.MAX_VALUE
}
private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = when {
this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless ->
abs(channels - preferredQuality.channels)
else -> 0
} }

View File

@ -0,0 +1,274 @@
package dev.krtirtho.spotube.modules.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
import dev.krtirtho.spotube.modules.settings.sections.SettingsItemIcon
import dev.krtirtho.spotube.modules.settings.sections.settingsSectionCard
import dev.krtirtho.spotube.modules.settings.sections.settingsSectionHeader
import dev.krtirtho.spotube.resources.iconsax.CustomServer
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxEye
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import dev.krtirtho.spotube.resources.iconsax.IconsaxTag
import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare
import dev.krtirtho.spotube.resources.iconsax.User
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_error_port_range
import spotube.composeapp.generated.resources.settings_error_whole_number
import spotube.composeapp.generated.resources.settings_jam_broker_client_id
import spotube.composeapp.generated.resources.settings_jam_broker_client_id_subtitle
import spotube.composeapp.generated.resources.settings_jam_broker_diagnostics_subtitle
import spotube.composeapp.generated.resources.settings_jam_broker_host
import spotube.composeapp.generated.resources.settings_jam_broker_password
import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note
import spotube.composeapp.generated.resources.settings_jam_broker_port
import spotube.composeapp.generated.resources.settings_jam_broker_test
import spotube.composeapp.generated.resources.settings_jam_broker_testing
import spotube.composeapp.generated.resources.settings_jam_broker_tls
import spotube.composeapp.generated.resources.settings_jam_broker_tls_subtitle
import spotube.composeapp.generated.resources.settings_jam_section_connection
import spotube.composeapp.generated.resources.settings_jam_section_credentials
import spotube.composeapp.generated.resources.settings_jam_section_diagnostics
import spotube.composeapp.generated.resources.settings_jam_section_identity
import spotube.composeapp.generated.resources.settings_jam_broker_username
import spotube.composeapp.generated.resources.settings_section_jam
@Composable
fun JamSettingsScreen(
viewModel: JamSettingsViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val broker = uiState.settings.jamBroker
val shellBottomInset = LocalAppShellBottomInset.current
val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
}
Scaffold(
topBar = {
ApplicationMainBar(
title = { Text(stringResource(Res.string.settings_section_jam)) },
)
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
LazyColumn(
modifier = Modifier
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter),
contentPadding = contentPadding,
) {
settingsSectionHeader(Res.string.settings_jam_section_connection)
settingsSectionCard(
items = listOf(
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_host),
value = broker.host,
placeholder = "broker.example.com",
icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") },
onValueSaved = { host ->
viewModel.update {
copy(
jamBroker = jamBroker.copy(
host = host
)
)
}
},
)
},
{
val errorWholeNumber =
stringResource(Res.string.settings_error_whole_number)
val errorPortRange =
stringResource(Res.string.settings_error_port_range)
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_port),
value = broker.port.toString(),
placeholder = "1883",
normalize = { it.filter(Char::isDigit).take(5) },
validate = { value ->
val port = value.toIntOrNull()
when {
port == null -> errorWholeNumber
port !in 1..65535 -> errorPortRange
else -> null
}
},
onValueSaved = { port ->
viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) }
},
icon = {
SettingsItemIcon(
Iconsax.CustomServer,
stringResource(Res.string.settings_jam_broker_port)
)
},
)
},
{
SwitchSettingCard(
title = stringResource(Res.string.settings_jam_broker_tls),
subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
checked = broker.useTls,
onCheckedChange = { tls ->
viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxWifiSquare,
stringResource(Res.string.settings_jam_broker_tls)
)
},
)
},
),
)
settingsSectionHeader(Res.string.settings_jam_section_credentials)
settingsSectionCard(
items = listOf(
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_username),
value = broker.username.orEmpty(),
placeholder = "anonymous",
onValueSaved = { username ->
viewModel.update {
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
}
},
icon = {
SettingsItemIcon(
Iconsax.User,
stringResource(Res.string.settings_jam_broker_username)
)
},
)
},
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_password),
value = broker.password.orEmpty(),
placeholder = "••••••••",
onValueSaved = { password ->
viewModel.update {
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
}
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxEye,
stringResource(Res.string.settings_jam_broker_password)
)
},
)
},
),
)
settingsSectionHeader(Res.string.settings_jam_section_identity)
settingsSectionCard(
items = listOf(
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_client_id),
subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
value = broker.clientIdPrefix,
placeholder = "spotube",
onValueSaved = { prefix ->
viewModel.update {
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
}
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxTag,
stringResource(Res.string.settings_jam_broker_client_id)
)
},
)
},
),
)
settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
settingsSectionCard(
items = listOf(
{
val canTest = broker.host.isNotBlank() && !uiState.isTesting
val resultMessage = uiState.testResult?.fold(
onSuccess = { it },
onFailure = { "Failed: ${it.message ?: "Connection failed"}" },
)
SettingCardItem(
enabled = canTest,
title = stringResource(Res.string.settings_jam_broker_test),
subtitle = when {
uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
resultMessage != null -> resultMessage
else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxInformation,
stringResource(Res.string.settings_jam_broker_test),
)
},
trailingContent = {
PrimaryButton(
onClick = viewModel::testConnection,
enabled = canTest,
) {
Text(
if (uiState.isTesting) {
stringResource(Res.string.settings_jam_broker_testing)
} else {
stringResource(Res.string.settings_jam_broker_test)
},
)
}
},
onClick = viewModel::testConnection,
)
},
),
)
item {
Text(
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
}
}
}

View File

@ -0,0 +1,51 @@
package dev.krtirtho.spotube.modules.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.jam.JamRoomClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class JamSettingsUiState(
val settings: UserSettings = UserSettings(),
val isTesting: Boolean = false,
val testResult: Result<String>? = null,
)
class JamSettingsViewModel(
private val settingsRepository: SettingsRepository,
private val jamRoomClient: JamRoomClient,
) : ViewModel() {
private val _uiState = MutableStateFlow(JamSettingsUiState())
val uiState: StateFlow<JamSettingsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
settingsRepository.userSettings.collect { settings ->
_uiState.update { it.copy(settings = settings) }
}
}
}
fun update(transform: UserSettings.() -> UserSettings) {
viewModelScope.launch {
settingsRepository.updateSettings(uiState.value.settings.transform())
}
}
fun testConnection() {
if (_uiState.value.isTesting) return
_uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch {
val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker)
_uiState.update { it.copy(isTesting = false, testResult = result) }
}
}
fun clearTestResult() {
_uiState.update { it.copy(testResult = null) }
}
}

View File

@ -114,7 +114,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
if (settingsState != null) if (settingsState != null)
jamSection( jamSection(
settings = settingsState!!, settings = settingsState!!,
settingsViewModel = settingsViewModel, navigationCommands = navigatorCommands,
) )
if (settingsState != null) if (settingsState != null)
cacheSection( cacheSection(
@ -142,4 +142,3 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
} }
} }
} }

View File

@ -5,202 +5,56 @@
* it under the terms of the GNU Affero General Public License as published by * 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 * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (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 dev.krtirtho.spotube.modules.settings.sections package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Button import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import compose.icons.FeatherIcons
import androidx.compose.runtime.getValue import compose.icons.feathericons.ChevronRight
import androidx.compose.runtime.mutableStateOf import dev.krtirtho.spotube.core.navigation.NavigationCommands
import androidx.compose.runtime.remember import dev.krtirtho.spotube.core.navigation.Routes
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.jam.JamRoomClient
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
import dev.krtirtho.spotube.resources.iconsax.CustomServer import dev.krtirtho.spotube.resources.iconsax.CustomServer
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_jam_broker_client_id
import spotube.composeapp.generated.resources.settings_jam_broker_host
import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle
import spotube.composeapp.generated.resources.settings_jam_broker_password import spotube.composeapp.generated.resources.settings_jam_broker_title
import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note
import spotube.composeapp.generated.resources.settings_jam_broker_port
import spotube.composeapp.generated.resources.settings_jam_broker_test
import spotube.composeapp.generated.resources.settings_jam_broker_test_fail
import spotube.composeapp.generated.resources.settings_jam_broker_test_ok
import spotube.composeapp.generated.resources.settings_jam_broker_testing
import spotube.composeapp.generated.resources.settings_jam_broker_tls
import spotube.composeapp.generated.resources.settings_jam_broker_username
import spotube.composeapp.generated.resources.settings_section_jam import spotube.composeapp.generated.resources.settings_section_jam
internal fun LazyListScope.jamSection( internal fun LazyListScope.jamSection(
settings: UserSettings, settings: UserSettings,
settingsViewModel: SettingsViewModel, navigationCommands: NavigationCommands,
) { ) {
val broker = settings.jamBroker
settingsSectionHeader(Res.string.settings_section_jam) settingsSectionHeader(Res.string.settings_section_jam)
settingsSectionCard( settingsSectionCard(
items = listOf( items = listOf(
{ {
TextInputSettingCard( SettingCardItem(
title = stringResource(Res.string.settings_jam_broker_host), title = stringResource(Res.string.settings_jam_broker_title),
subtitle = stringResource( subtitle = stringResource(
Res.string.settings_jam_broker_host_subtitle, Res.string.settings_jam_broker_host_subtitle,
broker.host.ifBlank { "—" }, settings.jamBroker.host.ifBlank { "—" },
broker.port, settings.jamBroker.port,
), ),
value = broker.host,
onValueSaved = { host ->
settingsViewModel.updateSettings {
copy(jamBroker = jamBroker.copy(host = host))
}
},
placeholder = "broker.example.com",
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.CustomServer, Iconsax.CustomServer,
stringResource(Res.string.settings_jam_broker_host), stringResource(Res.string.settings_jam_broker_title),
) )
}, },
trailingContent = {
Icon(
imageVector = FeatherIcons.ChevronRight,
contentDescription = stringResource(Res.string.settings_jam_broker_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}, },
{ onClick = { navigationCommands.navigateTo(Routes.JamSettings) },
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_port),
value = broker.port.toString(),
onValueSaved = { port ->
settingsViewModel.updateSettings {
copy(jamBroker = jamBroker.copy(port = port.toIntOrNull() ?: 1883))
}
},
placeholder = "1883",
normalize = { it.filter { c -> c.isDigit() }.take(5) },
validate = { input ->
val port = input.toIntOrNull()
if (port == null || port !in 1..65535) "Invalid port" else null
},
)
},
{
SwitchSettingCard(
title = stringResource(Res.string.settings_jam_broker_tls),
checked = broker.useTls,
onCheckedChange = { tls ->
settingsViewModel.updateSettings {
copy(jamBroker = jamBroker.copy(useTls = tls))
}
},
)
},
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_username),
value = broker.username.orEmpty(),
onValueSaved = { username ->
settingsViewModel.updateSettings {
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
}
},
placeholder = "anonymous",
)
},
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_password),
value = broker.password.orEmpty(),
onValueSaved = { password ->
settingsViewModel.updateSettings {
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
}
},
placeholder = "••••••••",
)
},
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_client_id),
value = broker.clientIdPrefix,
onValueSaved = { prefix ->
settingsViewModel.updateSettings {
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
}
},
placeholder = "spotube",
)
},
{
val jamClient = koinInject<JamRoomClient>()
val scope = rememberCoroutineScope()
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp)) {
Button(
onClick = {
testing = true
result = null
scope.launch {
val outcome = jamClient.testConnection(broker)
result = outcome.fold(
onSuccess = { ok -> "OK: $ok" },
onFailure = { e -> "ERR: ${e.message ?: "unknown"}" },
)
testing = false
}
},
enabled = broker.host.isNotBlank() && !testing,
) {
Text(
text = if (testing) {
stringResource(Res.string.settings_jam_broker_testing)
} else {
stringResource(Res.string.settings_jam_broker_test)
},
)
}
result?.let { message ->
val ok = message.startsWith("OK:")
Text(
text = message.removePrefix("OK:").removePrefix("ERR:"),
style = MaterialTheme.typography.bodySmall,
color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 12.dp),
)
}
}
},
{
Text(
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) )
}, },
) )

View File

@ -146,7 +146,7 @@ internal fun availableAudioQualities(format: AudioFormat, current: AudioQuality)
} }
internal fun AudioFormat.displayLabel(): String { internal fun AudioFormat.displayLabel(): String {
return "${codec.uppercase()} in ${container.uppercase()} • ${preferredQuality().displayLabel()}" return "${codec.uppercase()} in ${container.uppercase()}"
} }
internal fun AudioFormat.preferredQuality(): AudioQuality { internal fun AudioFormat.preferredQuality(): AudioQuality {

View File

@ -66,9 +66,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -77,7 +74,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
@ -86,7 +82,6 @@ import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
@ -155,6 +150,7 @@ fun AppExpandedPlayer(
onSleepTimer: () -> Unit = {}, onSleepTimer: () -> Unit = {},
audioPlayer: AudioPlayerInterface = koinInject(), audioPlayer: AudioPlayerInterface = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(),
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>( savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID, key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() } parameters = { parametersOf() }
@ -169,6 +165,7 @@ fun AppExpandedPlayer(
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val navigationCommands: NavigationCommands = koinInject() val navigationCommands: NavigationCommands = koinInject()
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle()
val currentTrack = remember(currentEntry) { val currentTrack = remember(currentEntry) {
(currentEntry as? QueueEntry.StreamingTrack)?.track (currentEntry as? QueueEntry.StreamingTrack)?.track
} }
@ -184,9 +181,10 @@ fun AppExpandedPlayer(
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
var isSeeking by remember { mutableStateOf(false) } var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
var showMoreOptionsSheet by remember { mutableStateOf(false) }
val moreOptionsSheetState = rememberModalBottomSheetState() val moreOptionsSheetState = rememberModalBottomSheetState()
PlayerOptionDialogs(viewModel = playerOptionsViewModel)
LaunchedEffect(playerUiState.progress, isSeeking) { LaunchedEffect(playerUiState.progress, isSeeking) {
if (!isSeeking) { if (!isSeeking) {
@ -241,10 +239,10 @@ fun AppExpandedPlayer(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
if (showMoreOptionsSheet) { if (playerOptionsUiState.isMoreOptionsSheetOpen) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
showMoreOptionsSheet = false playerOptionsViewModel.dismissMoreOptionsSheet()
}, },
sheetState = moreOptionsSheetState sheetState = moreOptionsSheetState
) { ) {
@ -261,7 +259,7 @@ fun AppExpandedPlayer(
icon = Iconsax.SwapHorizontal2, icon = Iconsax.SwapHorizontal2,
label = "Alternative Source", label = "Alternative Source",
onClick = { onClick = {
showMoreOptionsSheet = false playerOptionsViewModel.dismissMoreOptionsSheet()
onAlternativeSource() onAlternativeSource()
}, },
) )
@ -287,7 +285,7 @@ fun AppExpandedPlayer(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
.clickable { .clickable {
showMoreOptionsSheet = false playerOptionsViewModel.dismissMoreOptionsSheet()
when (status) { when (status) {
is DownloadStatus.Completed -> { is DownloadStatus.Completed -> {
scope.launch { scope.launch {
@ -375,7 +373,8 @@ fun AppExpandedPlayer(
icon = Iconsax.InconsaxClock, icon = Iconsax.InconsaxClock,
label = "Sleep Timer", label = "Sleep Timer",
onClick = { onClick = {
showMoreOptionsSheet = false playerOptionsViewModel.dismissMoreOptionsSheet()
playerOptionsViewModel.showSleepTimerDialog()
onSleepTimer() onSleepTimer()
}, },
) )
@ -385,7 +384,7 @@ fun AppExpandedPlayer(
icon = Iconsax.IconsaxCd, icon = Iconsax.IconsaxCd,
label = "Go to Album", label = "Go to Album",
onClick = { onClick = {
showMoreOptionsSheet = false playerOptionsViewModel.dismissMoreOptionsSheet()
val albumId = currentTrack?.album?.id val albumId = currentTrack?.album?.id
if (albumId != null) { if (albumId != null) {
navigationCommands.navigateTo(Routes.Album(albumId)) navigationCommands.navigateTo(Routes.Album(albumId))
@ -394,6 +393,16 @@ fun AppExpandedPlayer(
}, },
) )
} }
item {
OptionTile(
icon = Iconsax.Iconsax3DotsMore,
label = "Track Details",
onClick = {
playerOptionsViewModel.dismissMoreOptionsSheet()
playerOptionsViewModel.showTrackDetails()
},
)
}
} }
} }
} }
@ -422,7 +431,7 @@ fun AppExpandedPlayer(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
) )
GhostIconButton(onClick = { showMoreOptionsSheet = true }) { GhostIconButton(onClick = playerOptionsViewModel::showMoreOptionsSheet) {
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options")
} }
} }

View File

@ -18,7 +18,6 @@
package dev.krtirtho.spotube.modules.shell package dev.krtirtho.spotube.modules.shell
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -34,6 +33,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -54,19 +55,20 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.base.Slider
@ -80,7 +82,9 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
@ -94,12 +98,18 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_go_to_album
import spotube.composeapp.generated.resources.player_sleep_timer
import spotube.composeapp.generated.resources.player_track_details
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -122,6 +132,7 @@ fun AppLargePlayer(
audioPlayer: AudioPlayerInterface = koinInject(), audioPlayer: AudioPlayerInterface = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
downloadsViewModel: DownloadsViewModel = koinViewModel(), downloadsViewModel: DownloadsViewModel = koinViewModel(),
playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(),
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>( savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID, key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() } parameters = { parametersOf() }
@ -134,11 +145,16 @@ fun AppLargePlayer(
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track
val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle()
val navigationCommands: NavigationCommands = koinInject()
var isSeeking by remember { mutableStateOf(false) } var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) }
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
PlayerOptionDialogs(viewModel = playerOptionsViewModel)
LaunchedEffect(playerUiState.progress, isSeeking) { LaunchedEffect(playerUiState.progress, isSeeking) {
if (!isSeeking) { if (!isSeeking) {
seekProgress = playerUiState.progress seekProgress = playerUiState.progress
@ -383,9 +399,64 @@ fun AppLargePlayer(
GhostIconButton(onClick = onLyrics) { GhostIconButton(onClick = onLyrics) {
Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics")
} }
GhostIconButton(onClick = onMoreOptions) { Box {
GhostIconButton(
onClick = {
playerOptionsViewModel.showMoreOptionsMenu()
onMoreOptions()
}
) {
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
} }
DropdownMenu(
expanded = playerOptionsUiState.isMoreOptionsMenuOpen,
onDismissRequest = playerOptionsViewModel::dismissMoreOptionsMenu,
) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.player_go_to_album)) },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxCd,
contentDescription = null,
)
},
enabled = currentTrack?.album?.id != null,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
currentTrack?.album?.id?.let { albumId ->
navigationCommands.navigateTo(Routes.Album(albumId))
}
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.player_sleep_timer)) },
leadingIcon = {
Icon(
imageVector = Iconsax.InconsaxClock,
contentDescription = null,
)
},
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showSleepTimerDialog()
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.player_track_details)) },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxInformation,
contentDescription = null,
)
},
enabled = currentTrack != null,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showTrackDetails()
},
)
}
}
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
GhostIconButton( GhostIconButton(

View File

@ -0,0 +1,401 @@
/*
* 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 dev.krtirtho.spotube.modules.shell
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.core.server.CacheEntry
@Composable
fun PlayerOptionDialogs(viewModel: PlayerOptionsViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
if (uiState.isSleepTimerDialogOpen) {
SleepTimerDialog(
minutes = uiState.sleepTimerMinutes,
remainingMs = uiState.sleepTimerRemainingMs,
onMinutesChange = viewModel::updateSleepTimerMinutes,
onStart = viewModel::startSleepTimer,
onCancelTimer = viewModel::cancelSleepTimer,
onDismiss = viewModel::dismissSleepTimerDialog,
)
}
uiState.trackDetails?.let { details ->
TrackDetailsDialog(
details = details,
onDismiss = viewModel::dismissTrackDetails,
)
}
}
@Composable
private fun SleepTimerDialog(
minutes: String,
remainingMs: Long?,
onMinutesChange: (String) -> Unit,
onStart: () -> Unit,
onCancelTimer: () -> Unit,
onDismiss: () -> Unit,
) {
ThemedDialog(
onDismissRequest = onDismiss,
title = {
Text("Sleep timer", style = MaterialTheme.typography.titleLarge)
},
actions = {
if (remainingMs != null) {
SecondaryButton(onClick = onCancelTimer) { Text("Cancel timer") }
}
SecondaryButton(onClick = onDismiss) { Text("Close") }
PrimaryButton(
onClick = onStart,
enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true,
) {
Text("Start")
}
},
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (remainingMs != null) {
Text(
text = "Playback will pause in ${formatRemainingTime(remainingMs)}.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
text = "Pause playback after the selected number of minutes.",
style = MaterialTheme.typography.bodyMedium,
)
}
OutlinedTextField(
value = minutes,
onValueChange = onMinutesChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("Minutes") },
supportingText = { Text("Choose between 1 and 600 minutes") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
}
}
}
@Composable
private fun TrackDetailsDialog(
details: PlayerTrackDetails,
onDismiss: () -> Unit,
) {
ThemedDialog(
onDismissRequest = onDismiss,
title = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Track details", style = MaterialTheme.typography.titleLarge)
Text(
details.track.title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
actions = {
SecondaryButton(onClick = onDismiss) { Text("Close") }
},
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
if (details.isLoading) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
if (details.localCacheEntry != null) {
LocalCacheBanner(details.localCacheEntry)
}
if (details.localCacheEntry == null && details.activeStreamMatchesPreference == false) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
Text(
"The playback URL was resolved with different streaming settings. The highlighted stream matches your current preference; the active URL refreshes on the next stream request.",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
TrackMetadataSection(details.track)
Text(
"Matched audio source",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
val source = details.matchedSource
if (details.isLoading) {
Text("Loading matched source…", style = MaterialTheme.typography.bodySmall)
} else if (source == null) {
Text(
details.error ?: if (details.localCacheEntry != null) {
"A matched remote source is not needed while playing the local cached file."
} else {
"No matched source is available."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
DetailRow("Title", source.title)
DetailRow("ID", source.id)
DetailRow("Artist", source.artist)
DetailRow("Album", source.album)
DetailRow("Confidence", "${(source.confidence * 100).toInt()}%")
DetailRow("External URI", source.externalUri)
}
Text(
"Available streams",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
when {
details.localCacheEntry != null && details.streams.isEmpty() -> Text(
"This track is being served from the local cache. Stream variants are not used for this playback.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
details.isLoading -> Text("Loading streams…", style = MaterialTheme.typography.bodySmall)
details.streams.isEmpty() -> Text(
details.error ?: "No streams are available for this source.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> details.streams.forEach { stream ->
val isSelected = stream == details.preferredStream
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = streamFormatAndQuality(stream),
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
if (isSelected) {
Icon(
imageVector = Iconsax.IconsaxCheckCircle,
contentDescription = "Currently selected stream",
tint = MaterialTheme.colorScheme.primary,
)
}
}
Text(
"${stream.protocol} • ${stream.codec} • ${stream.container}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stream.url,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (isSelected) {
Text(
"Matches your streaming format & quality preference",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
details.activeStreamUrl?.let { activeUrl ->
DetailRow("Active playback URL", activeUrl)
}
}
}
}
@Composable
private fun LocalCacheBanner(entry: CacheEntry) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Iconsax.IconsaxDirectboxReceive,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"Served from local cache",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
"${entry.filename} • ${formatFileSize(entry.sizeBytes)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
}
@Composable
private fun TrackMetadataSection(track: MetadataTrack) {
Text(
"Metadata track",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
DetailRow("ID", track.id)
DetailRow("Title", track.title)
DetailRow("Artists", track.artists.joinToString { it.name })
DetailRow("Album", track.album?.title)
DetailRow("Album ID", track.album?.id)
DetailRow("Album type", track.album?.albumType?.name)
DetailRow("Album release date", track.album?.releaseDate)
DetailRow("Album track count", track.album?.trackCount?.toString())
DetailRow("Album genres", track.album?.genres?.joinToString())
DetailRow("Duration", formatRemainingTime(track.durationMs))
DetailRow("Track number", track.trackNumber?.toString())
DetailRow("Disc number", track.discNumber?.toString())
DetailRow("Explicit", track.explicit?.toString())
DetailRow("Popularity", track.popularity?.toString())
DetailRow("ISRC", track.isrcCode)
DetailRow("External URI", track.externalUri)
DetailRow("Thumbnail URLs", track.thumbnails?.joinToString { it.url })
}
@Composable
private fun DetailRow(label: String, value: String?) {
if (value.isNullOrBlank()) return
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
label,
modifier = Modifier.weight(0.38f),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
value,
modifier = Modifier.weight(0.62f),
style = MaterialTheme.typography.bodySmall,
)
}
}
private fun streamFormatAndQuality(stream: AudioStream): String = when (stream) {
is AudioStream.Lossy -> "${stream.bitrate / 1_000} kbps"
is AudioStream.Lossless -> {
val rate = if (stream.sampleRate % 1_000 == 0) {
"${stream.sampleRate / 1_000}"
} else {
"${stream.sampleRate / 1_000.0}"
}
"$rate kHz • ${stream.channels} ch"
}
}
private fun formatRemainingTime(milliseconds: Long): String {
val totalSeconds = (milliseconds / 1_000).coerceAtLeast(0)
val hours = totalSeconds / 3_600
val minutes = (totalSeconds % 3_600) / 60
val seconds = totalSeconds % 60
return if (hours > 0) {
"$hours:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
} else {
"$minutes:${seconds.toString().padStart(2, '0')}"
}
}
private fun formatFileSize(sizeBytes: Long): String = when {
sizeBytes >= 1024L * 1024L -> "${sizeBytes / (1024L * 1024L)} MB"
sizeBytes >= 1024L -> "${sizeBytes / 1024L} KB"
else -> "$sizeBytes B"
}

View File

@ -0,0 +1,241 @@
/*
* 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 dev.krtirtho.spotube.modules.shell
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.server.CacheEntry
import dev.krtirtho.spotube.core.server.CacheManager
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
import dev.krtirtho.spotube.core.server.normalizeManifestUrl
import dev.krtirtho.spotube.core.server.selectPreferredAudioStream
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
data class PlayerTrackDetails(
val track: MetadataTrack,
val matchedSource: AudioSource.Basic? = null,
val streams: List<AudioStream> = emptyList(),
/** Stream picked by the current streaming format/quality preference. */
val preferredStream: AudioStream? = null,
/** URL currently used for playback, read from the playback URL cache. */
val activeStreamUrl: String? = null,
/** Whether the active URL matches what the current preferences select. */
val activeStreamMatchesPreference: Boolean? = null,
val localCacheEntry: CacheEntry? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
data class PlayerOptionsUiState(
val isMoreOptionsMenuOpen: Boolean = false,
val isMoreOptionsSheetOpen: Boolean = false,
val isSleepTimerDialogOpen: Boolean = false,
val sleepTimerMinutes: String = "30",
val sleepTimerRemainingMs: Long? = null,
val trackDetails: PlayerTrackDetails? = null,
)
class PlayerOptionsViewModel(
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: MatchedTracksRepository,
private val streamingUrlRepository: StreamingUrlRepository,
private val cacheManager: CacheManager,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerOptionsUiState())
val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow()
private var sleepTimerJob: Job? = null
private var trackDetailsJob: Job? = null
fun showMoreOptionsMenu() {
_uiState.update {
it.copy(isMoreOptionsMenuOpen = true, isMoreOptionsSheetOpen = false)
}
}
fun dismissMoreOptionsMenu() {
_uiState.update { it.copy(isMoreOptionsMenuOpen = false) }
}
fun showMoreOptionsSheet() {
_uiState.update {
it.copy(isMoreOptionsSheetOpen = true, isMoreOptionsMenuOpen = false)
}
}
fun dismissMoreOptionsSheet() {
_uiState.update { it.copy(isMoreOptionsSheetOpen = false) }
}
fun showSleepTimerDialog() {
_uiState.update { it.copy(isSleepTimerDialogOpen = true) }
}
fun dismissSleepTimerDialog() {
_uiState.update { it.copy(isSleepTimerDialogOpen = false) }
}
fun updateSleepTimerMinutes(minutes: String) {
if (minutes.all(Char::isDigit)) {
_uiState.update { it.copy(sleepTimerMinutes = minutes) }
}
}
fun startSleepTimer() {
val minutes = _uiState.value.sleepTimerMinutes.toLongOrNull()
?.takeIf { it in 1..600 } ?: return
val durationMs = minutes * MILLIS_PER_MINUTE
sleepTimerJob?.cancel()
sleepTimerJob = viewModelScope.launch {
var remainingMs = durationMs
while (remainingMs > 0L) {
_uiState.update { it.copy(sleepTimerRemainingMs = remainingMs) }
val tickMs = minOf(remainingMs, MILLIS_PER_SECOND)
delay(tickMs.milliseconds)
remainingMs -= tickMs
}
runCatching { audioPlayer.pause() }
_uiState.update { it.copy(sleepTimerRemainingMs = null, isSleepTimerDialogOpen = false) }
}
}
fun cancelSleepTimer() {
sleepTimerJob?.cancel()
sleepTimerJob = null
_uiState.update { it.copy(sleepTimerRemainingMs = null) }
}
fun showTrackDetails() {
val track = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)
?.track ?: return
trackDetailsJob?.cancel()
_uiState.update {
it.copy(
trackDetails = PlayerTrackDetails(track = track, isLoading = true),
)
}
trackDetailsJob = viewModelScope.launch {
runCatching {
val settings = settingsRepository.userSettings.value
withContext(Dispatchers.IO) {
val matchedSource = matchedTracksRepository.getTrackSource(track)
val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id)
val localCacheEntry = if (settings.enableMusicCaching) {
cacheManager.findCachedEntry(track.id)?.second
} else {
null
}
val cachedStreamSource = cachedStream?.source?.takeIf {
matchedSource == null || it.id == matchedSource.id
}
val source = matchedSource ?: cachedStreamSource?.toBasic()
val streams = cachedStreamSource?.streams.orEmpty()
val preferredStream = selectPreferredAudioStream(
streams = streams,
preferredFormat = settings.streamingMusicFormat,
preferredQuality = settings.streamingMusicQuality,
)
val activeStreamUrl = if (cachedStreamSource != null) cachedStream.url else null
val activeStreamMatchesPreference = when {
activeStreamUrl == null || preferredStream == null -> null
else -> activeStreamUrl == normalizeManifestUrl(
url = preferredStream.url,
protocol = preferredStream.protocol,
)
}
PlayerTrackDetails(
track = track,
matchedSource = source,
streams = streams,
preferredStream = preferredStream,
activeStreamUrl = activeStreamUrl,
activeStreamMatchesPreference = activeStreamMatchesPreference,
localCacheEntry = localCacheEntry,
error = when {
source == null && localCacheEntry == null ->
"No matched audio source is cached for this track."
streams.isEmpty() && localCacheEntry == null ->
"Stream details are not cached yet. Play the track first to load them."
else -> null
},
)
}
}.onSuccess { details ->
_uiState.update { current ->
if (current.trackDetails?.track?.id == track.id) {
current.copy(trackDetails = details)
} else current
}
}.onFailure { error ->
if (error is CancellationException) throw error
_uiState.update { current ->
if (current.trackDetails?.track?.id == track.id) {
current.copy(
trackDetails = PlayerTrackDetails(
track = track,
isLoading = false,
error = error.message ?: "Failed to load track details.",
),
)
} else current
}
}
}
}
fun dismissTrackDetails() {
trackDetailsJob?.cancel()
trackDetailsJob = null
_uiState.update { it.copy(trackDetails = null) }
}
override fun onCleared() {
sleepTimerJob?.cancel()
trackDetailsJob?.cancel()
super.onCleared()
}
companion object {
private const val MILLIS_PER_SECOND = 1_000L
private const val MILLIS_PER_MINUTE = 60_000L
}
}

View File

@ -0,0 +1,434 @@
package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame
/**
* Tests for the stream preference selection used by [StreamingUrlRepository].
*
* Ordering contract (best first):
* 1. Fewest codec/container mismatches against the preferred format.
* 2. Matching quality family (lossy vs lossless).
* 3. Closest bitrate / sample rate.
* 4. Closest channel count (lossless only).
* 5. First occurrence wins remaining ties.
*/
class StreamingUrlRepositoryTest {
// ---------- helpers ----------
private fun opusWebm() = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
private fun flac() = AudioFormat(codec = "flac", container = "flac", qualities = emptyList())
private fun lossy(
url: String,
bitrate: Int,
codec: String = "opus",
container: String = "webm",
protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
) = AudioStream.Lossy(
url = url,
codec = codec,
container = container,
protocol = protocol,
bitrate = bitrate,
)
private fun lossless(
url: String,
sampleRate: Int,
channels: Int,
codec: String = "flac",
container: String = "flac",
) = AudioStream.Lossless(
url = url,
codec = codec,
container = container,
sampleRate = sampleRate,
channels = channels,
)
private fun select(
streams: List<AudioStream>,
format: AudioFormat = opusWebm(),
quality: AudioQuality = AudioQuality.Lossy(256_000),
) = selectPreferredAudioStream(streams, format, quality)
// ---------- empty / single ----------
@Test
fun `returns null when no streams are available`() {
assertNull(select(emptyList()))
}
@Test
fun `returns the only stream when exactly one is available`() {
val only = lossy("opus-128", 128_000)
assertSame(only, select(listOf(only)))
}
@Test
fun `does not mutate the input list`() {
val streams = mutableListOf(
lossy("opus-44", 44_000),
lossy("opus-256", 256_000),
)
val snapshot = streams.toList()
select(streams)
assertEquals(snapshot, streams)
}
// ---------- format priority ----------
@Test
fun `prefers an exact codec and container match over a closer quality in another format`() {
val otherFormat = lossy("aac-256", 256_000, codec = "aac", container = "mp4")
val preferredFormat = lossy("opus-128", 128_000)
val result = select(
streams = listOf(otherFormat, preferredFormat),
quality = AudioQuality.Lossy(256_000),
)
assertSame(preferredFormat, result)
}
@Test
fun `format matching ignores codec and container case`() {
val exact = lossy("opus-128", 128_000, codec = "OpUs", container = "WeBm")
val mismatch = lossy("aac-128", 128_000, codec = "aac", container = "mp4")
val result = select(
streams = listOf(mismatch, exact),
format = AudioFormat(codec = "OPUS", container = "WEBM", qualities = emptyList()),
)
assertSame(exact, result)
}
@Test
fun `a partial format match beats a full format mismatch regardless of bitrate`() {
val partial = lossy("opus-mp4-320", 320_000, container = "mp4")
val fullMismatch = lossy("aac-mp4-44", 44_000, codec = "aac", container = "mp4")
val result = select(
streams = listOf(fullMismatch, partial),
quality = AudioQuality.Lossy(44_000),
)
assertSame(partial, result)
}
@Test
fun `a one-field match beats a two-field mismatch`() {
val oneField = lossy("opus-ogg", 128_000, container = "ogg")
val twoFields = lossy("aac-mp4", 128_000, codec = "aac", container = "mp4")
val result = select(streams = listOf(twoFields, oneField))
assertSame(oneField, result)
}
@Test
fun `codec-only and container-only matches tie on format and are resolved by quality`() {
val codecOnly = lossy("opus-mp4-320", 320_000, container = "mp4")
val containerOnly = lossy("aac-webm-44", 44_000, codec = "aac", container = "webm")
val result = select(
streams = listOf(codecOnly, containerOnly),
quality = AudioQuality.Lossy(44_000),
)
assertSame(containerOnly, result)
}
@Test
fun `when nothing matches the format the closest quality is chosen`() {
val low = lossy("aac-128", 128_000, codec = "aac", container = "mp4")
val target = lossy("aac-256", 256_000, codec = "aac", container = "mp4")
val high = lossy("aac-320", 320_000, codec = "aac", container = "mp4")
val result = select(
streams = listOf(low, target, high),
quality = AudioQuality.Lossy(256_000),
)
assertSame(target, result)
}
@Test
fun `format match is prioritized over matching the quality family`() {
// Same format but wrong family vs. wrong format but correct family.
val sameFormatWrongFamily = lossless("opus-webm-flac", 48_000, 2, codec = "opus", container = "webm")
val wrongFormatRightFamily = lossy("aac-44", 44_000, codec = "aac", container = "mp4")
val result = select(
streams = listOf(wrongFormatRightFamily, sameFormatWrongFamily),
quality = AudioQuality.Lossy(44_000),
)
assertSame(sameFormatWrongFamily, result)
}
// ---------- lossy quality ----------
@Test
fun `exact lossy bitrate wins`() {
val low = lossy("opus-44", 44_000)
val exact = lossy("opus-128", 128_000)
val high = lossy("opus-256", 256_000)
val result = select(
streams = listOf(low, exact, high),
quality = AudioQuality.Lossy(128_000),
)
assertSame(exact, result)
}
@Test
fun `closest lower lossy bitrate wins`() {
val lower = lossy("opus-128", 128_000)
val higher = lossy("opus-320", 320_000)
val result = select(
streams = listOf(higher, lower),
quality = AudioQuality.Lossy(200_000),
)
assertSame(lower, result)
}
@Test
fun `closest higher lossy bitrate wins`() {
val lower = lossy("opus-44", 44_000)
val higher = lossy("opus-128", 128_000)
val result = select(
streams = listOf(lower, higher),
quality = AudioQuality.Lossy(100_000),
)
assertSame(higher, result)
}
@Test
fun `equidistant lossy bitrates pick the first occurrence`() {
val below = lossy("opus-96", 96_000)
val above = lossy("opus-104", 104_000)
assertSame(below, select(listOf(below, above), quality = AudioQuality.Lossy(100_000)))
assertSame(above, select(listOf(above, below), quality = AudioQuality.Lossy(100_000)))
}
@Test
fun `lossy preference prefers a lossy stream over lossless in the same format`() {
val losslessStream = lossless("opus-webm-lossless", 48_000, 2, codec = "opus", container = "webm")
val lossyStream = lossy("opus-256", 256_000)
val result = select(
streams = listOf(losslessStream, lossyStream),
quality = AudioQuality.Lossy(128_000),
)
assertSame(lossyStream, result)
}
@Test
fun `lossy distance does not overflow for extreme bitrates`() {
val min = lossy("opus-min", Int.MIN_VALUE)
val max = lossy("opus-max", Int.MAX_VALUE)
val result = select(
streams = listOf(min, max),
quality = AudioQuality.Lossy(Int.MAX_VALUE),
)
assertSame(max, result)
}
// ---------- lossless quality ----------
@Test
fun `exact lossless sample rate and channels wins`() {
val otherRate = lossless("flac-44", 44_100, 2)
val mono = lossless("flac-48-mono", 48_000, 1)
val exact = lossless("flac-48-stereo", 48_000, 2)
val result = select(
streams = listOf(otherRate, mono, exact),
format = flac(),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(exact, result)
}
@Test
fun `closest lossless sample rate wins`() {
val near = lossless("flac-44", 44_100, 2)
val far = lossless("flac-96", 96_000, 2)
val result = select(
streams = listOf(far, near),
format = flac(),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(near, result)
}
@Test
fun `lossless sample rate tie is broken by channel count`() {
val mono = lossless("flac-44-mono", 44_100, 1)
val stereo = lossless("flac-44-stereo", 44_100, 2)
val result = select(
streams = listOf(mono, stereo),
format = flac(),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(stereo, result)
}
@Test
fun `lossless preference prefers a lossless stream over lossy in the same format`() {
val lossyStream = lossy("flac-flac-lossy", 128_000, codec = "flac", container = "flac")
val losslessStream = lossless("flac-44", 44_100, 2)
val result = select(
streams = listOf(lossyStream, losslessStream),
format = flac(),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(losslessStream, result)
}
// ---------- quality-family fallbacks ----------
@Test
fun `lossy preference with only lossless streams still returns the first stream`() {
val first = lossless("flac-44", 44_100, 2)
val second = lossless("flac-48", 48_000, 2)
val result = select(
streams = listOf(first, second),
format = flac(),
quality = AudioQuality.Lossy(256_000),
)
assertSame(first, result)
}
@Test
fun `lossless preference with only lossy streams still returns the first stream`() {
val first = lossy("opus-128", 128_000)
val second = lossy("opus-320", 320_000)
val result = select(
streams = listOf(first, second),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(first, result)
}
// ---------- degenerate / orthogonal inputs ----------
@Test
fun `empty preferred codec and container fall back to quality`() {
val low = lossy("opus-44", 44_000)
val target = lossy("opus-256", 256_000)
val result = select(
streams = listOf(low, target),
format = AudioFormat(codec = "", container = "", qualities = emptyList()),
quality = AudioQuality.Lossy(256_000),
)
assertSame(target, result)
}
@Test
fun `protocol does not influence selection`() {
val progressive = lossy("a", 128_000, protocol = StreamProtocol.PROGRESSIVE)
val dash = lossy("b", 128_000, protocol = StreamProtocol.DASH)
val hls = lossy("c", 128_000, protocol = StreamProtocol.HLS)
val result = select(streams = listOf(progressive, dash, hls))
assertSame(progressive, result)
}
@Test
fun `selection is stable across repeated calls`() {
val streams = listOf(
lossy("opus-44", 44_000),
lossy("opus-128", 128_000),
lossy("opus-256", 256_000),
)
val first = select(streams, quality = AudioQuality.Lossy(100_000))
val second = select(streams, quality = AudioQuality.Lossy(100_000))
assertSame(first, second)
}
// ---------- normalizeManifestUrl ----------
@Test
fun `absolute http urls are returned unchanged`() {
val url = "http://example.com/audio.mp3"
assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE))
}
@Test
fun `absolute https urls are returned unchanged for every protocol`() {
val url = "https://example.com/manifest.m3u8"
assertEquals(url, normalizeManifestUrl(url, StreamProtocol.HLS))
assertEquals(url, normalizeManifestUrl(url, StreamProtocol.DASH))
assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE))
}
@Test
fun `relative hls manifest paths are resolved against the youtube origin`() {
assertEquals(
"https://www.youtube.com/watch?v=abc",
normalizeManifestUrl("/watch?v=abc", StreamProtocol.HLS),
)
}
@Test
fun `relative dash manifest paths are resolved against the youtube origin`() {
assertEquals(
"https://www.youtube.com/watch?v=abc",
normalizeManifestUrl("/watch?v=abc", StreamProtocol.DASH),
)
}
@Test
fun `relative progressive urls are returned unchanged`() {
assertEquals(
"/watch?v=abc",
normalizeManifestUrl("/watch?v=abc", StreamProtocol.PROGRESSIVE),
)
}
@Test
fun `empty url is handled without throwing`() {
assertEquals("", normalizeManifestUrl("", StreamProtocol.PROGRESSIVE))
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH))
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
}
}

View File

@ -28,6 +28,7 @@ data class AudioFormat(
val qualities: List<AudioQuality> val qualities: List<AudioQuality>
) )
@Serializable
sealed interface AudioQuality { sealed interface AudioQuality {
@Serializable @Serializable
@SerialName("lossy") @SerialName("lossy")

View File

@ -0,0 +1,28 @@
package dev.krtirtho.plugin_interfaces.plugin_apis.audio
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
class AudioQualitySerializationTest {
@Test
fun `audio format serializes sealed quality variants`() {
val formats = listOf(
AudioFormat(
codec = "opus",
container = "webm",
qualities = listOf(AudioQuality.Lossy(bitrate = 256_000)),
),
AudioFormat(
codec = "flac",
container = "flac",
qualities = listOf(AudioQuality.Lossless(sampleRate = 48_000, channels = 2)),
),
)
val encoded = Json.encodeToString(formats)
val decoded = Json.decodeFromString<List<AudioFormat>>(encoded)
assertEquals(formats, decoded)
}
}