From 1e6aee142bda87108bdbeaac22988cc10b4d731d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 14:53:10 +0600 Subject: [PATCH] feat(player-options): add PlayerOptionsViewModel and enhance player UI with options menu --- .opencode/opencode.jsonc | 15 + .vscode/settings.json | 4 + AGENTS.md | 17 +- .../spotube/core/audioplayer/AudioPlayer.kt | 30 -- .../core/audioplayer/AudioPlayerModels.kt | 27 ++ .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../spotube/core/server/StreamProxy.kt | 7 +- .../core/server/StreamingUrlRepository.kt | 177 +++++-- .../modules/settings/sections/utils.kt | 2 +- .../modules/shell/AppExpandedPlayer.kt | 37 +- .../spotube/modules/shell/AppLargePlayer.kt | 57 ++- .../modules/shell/PlayerOptionDialogs.kt | 401 ++++++++++++++++ .../modules/shell/PlayerOptionsViewModel.kt | 241 ++++++++++ .../core/server/StreamingUrlRepositoryTest.kt | 434 ++++++++++++++++++ .../plugin_apis/audio/AudioModels.kt | 1 + .../audio/AudioQualitySerializationTest.kt | 28 ++ 16 files changed, 1382 insertions(+), 98 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt create mode 100644 plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 6dbeab07..a8304a54 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -3,6 +3,7 @@ "mcp": { "compose-hot-reload": { "enabled": true, + "disabled": false, "type": "local", "command": [ "./gradlew", @@ -11,6 +12,20 @@ "--console=plain", "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" + ] } } } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b2f9a94e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "gradle.autoDetect": "off", + "java.gradle.buildServer.enabled": "off" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index ee383842..7c7a90a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,4 +56,19 @@ - **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release. - **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. -- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached. \ No newline at end of file +- `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` 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. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt index 8587e043..67cbe65f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt @@ -17,40 +17,10 @@ package dev.krtirtho.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow 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 { // // Playback diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt index 1562a2e1..da119020 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt @@ -21,6 +21,33 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName 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 sealed interface QueueEntry { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index a130729f..9c9c7964 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -75,6 +75,7 @@ import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsViewModel 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.player_queue.PlayerQueueContentViewModel import org.koin.core.module.Module @@ -96,6 +97,7 @@ val sharedModules = module { // Shell viewModelOf(::AppShellViewModel) + viewModelOf(::PlayerOptionsViewModel) viewModelOf(::PlayerQueueContentViewModel) viewModelOf(::AlternativeTrackContentViewModel) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt index a937bc65..ede7b89c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt @@ -53,6 +53,7 @@ import okio.SYSTEM import okio.buffer import okio.use import org.koin.core.component.KoinComponent +import kotlin.time.Duration.Companion.milliseconds internal class StreamProxy( private val httpClient: HttpClient, @@ -114,7 +115,7 @@ internal class StreamProxy( logger.d { "Track $trackId is being cached, waiting for cache" } var waited = 0 while (waited < 30) { - delay(200) + delay(200.milliseconds) waited++ val entry = cacheManager.findCachedEntry(trackId) if (entry != null) { @@ -245,7 +246,7 @@ internal class StreamProxy( call.respond(object : OutgoingContent.WriteChannelContent() { override val status: HttpStatusCode = HttpStatusCode.OK 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) { channel.writeFully(rewrittenManifest.encodeToByteArray()) @@ -305,7 +306,7 @@ internal class StreamProxy( var currentUrl = streamUrl var attemptedRefresh = false - var upstream: HttpResponse? = null + var upstream: HttpResponse? while (true) { val response = runCatching { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt index c3af11d4..00a215c5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt @@ -18,15 +18,20 @@ package dev.krtirtho.spotube.core.server 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.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.modules.plugin.PluginManager +import dev.krtirtho.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.component.KoinComponent +import kotlin.math.abs import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -36,6 +41,10 @@ data class CachedStreamUrl( val expiresAtMs: Long, val container: String, val codec: String, + val source: AudioSource.Streamed? = null, + val selectedStream: AudioStream? = null, + val preferredFormat: AudioFormat, + val preferredQuality: AudioQuality, ) data class StreamInfo( @@ -45,11 +54,11 @@ data class StreamInfo( val container: String, ) - class StreamingUrlRepository( private val pluginManager: PluginManager, private val audioPlayerQueue: AudioPlayerQueue, private val matchedTracksRepository: MatchedTracksRepository, + private val settingsRepository: SettingsRepository, ) : KoinComponent { companion object { @@ -62,15 +71,6 @@ class StreamingUrlRepository( private val alternativesCacheMutex = Mutex() private val alternativesCache = mutableMapOf>() - 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( trackId: String, forceRefresh: Boolean = false @@ -91,13 +91,16 @@ class StreamingUrlRepository( forceRefresh: Boolean = false ): StreamInfo? { val trackId = track.id + val settings = settingsRepository.userSettings.value + val preferredFormat = settings.streamingMusicFormat + val preferredQuality = settings.streamingMusicQuality + if (!forceRefresh) { - getCachedStreamUrl(trackId)?.let { cached -> + getCachedStreamUrl(trackId, preferredFormat, preferredQuality)?.let { cached -> logger.v { "Using cached stream URL for track $trackId" } return cached } } - val audioPlugin = pluginManager.selectedAudioPlugin.value if (audioPlugin == null) { logger.w { "No audio plugin selected while resolving stream for track $trackId" } @@ -120,16 +123,14 @@ class StreamingUrlRepository( if (stream != null) { logger.d { "Resolved stream for track $trackId using cached source" } - val resolvedStream = stream.streams.firstOrNull() + val resolvedStream = selectPreferredAudioStream( + stream.streams, + preferredFormat, + preferredQuality, + ) if (resolvedStream != null) { - val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol) - val info = StreamInfo( - url, - resolvedStream.protocol, - resolvedStream.codec, - resolvedStream.container - ) - cacheStreamInfo(trackId, info) + val info = resolvedStream.toStreamInfo() + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream) return info } } @@ -182,45 +183,66 @@ class StreamingUrlRepository( } logger.d { "Resolved stream for track $trackId via track lookup" } - val resolvedStream = stream.streams.firstOrNull() + val resolvedStream = selectPreferredAudioStream( + stream.streams, + preferredFormat, + preferredQuality, + ) if (resolvedStream != null) { - val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol) - val info = StreamInfo( - url, - resolvedStream.protocol, - resolvedStream.codec, - resolvedStream.container - ) - cacheStreamInfo(trackId, info) + val info = resolvedStream.toStreamInfo() + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream) return info } 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() return streamUrlCacheMutex.withLock { val cached = streamUrlCache[trackId] ?: return@withLock null + if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) { + return@withLock null + } if (cached.expiresAtMs <= now) { - streamUrlCache.remove(trackId) logger.v { "Cached stream URL expired for track $trackId" } 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 streamUrlCacheMutex.withLock { - streamUrlCache[trackId] = - CachedStreamUrl( - url = info.url, - protocol = info.protocol, - expiresAtMs = expiresAtMs, - container = info.container, - codec = info.codec - ) + streamUrlCache[trackId] = CachedStreamUrl( + url = info.url, + protocol = info.protocol, + expiresAtMs = expiresAtMs, + container = info.container, + 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" } } } + // Stream metadata is part of the same URL-cache entry and is removed with it. } suspend fun getCachedAlternatives(trackId: String): List? { @@ -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) { alternativesCacheMutex.withLock { alternativesCache.remove(trackId) } } -} \ No newline at end of file + +} + +/** + * 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, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, +): AudioStream? = streams.minWithOrNull( + compareBy { 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 +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt index 1776e14f..4a897cf8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt @@ -146,7 +146,7 @@ internal fun availableAudioQualities(format: AudioFormat, current: AudioQuality) } internal fun AudioFormat.displayLabel(): String { - return "${codec.uppercase()} in ${container.uppercase()} • ${preferredQuality().displayLabel()}" + return "${codec.uppercase()} in ${container.uppercase()}" } internal fun AudioFormat.preferredQuality(): AudioQuality { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index 96172f80..0016bc80 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -66,9 +66,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.layout.ContentScale 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.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.AudioPlayerQueue 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.navigation.NavigationCommands 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.IconButton import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme @@ -155,6 +150,7 @@ fun AppExpandedPlayer( onSleepTimer: () -> Unit = {}, audioPlayer: AudioPlayerInterface = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(), + playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(), savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } @@ -169,6 +165,7 @@ fun AppExpandedPlayer( val downloadsViewModel: DownloadsViewModel = koinViewModel() val navigationCommands: NavigationCommands = koinInject() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle() val currentTrack = remember(currentEntry) { (currentEntry as? QueueEntry.StreamingTrack)?.track } @@ -184,9 +181,10 @@ fun AppExpandedPlayer( val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } - var showMoreOptionsSheet by remember { mutableStateOf(false) } val moreOptionsSheetState = rememberModalBottomSheetState() + PlayerOptionDialogs(viewModel = playerOptionsViewModel) + LaunchedEffect(playerUiState.progress, isSeeking) { if (!isSeeking) { @@ -241,10 +239,10 @@ fun AppExpandedPlayer( modifier = modifier.fillMaxSize(), snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> - if (showMoreOptionsSheet) { + if (playerOptionsUiState.isMoreOptionsSheetOpen) { ModalBottomSheet( onDismissRequest = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() }, sheetState = moreOptionsSheetState ) { @@ -261,7 +259,7 @@ fun AppExpandedPlayer( icon = Iconsax.SwapHorizontal2, label = "Alternative Source", onClick = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() onAlternativeSource() }, ) @@ -287,7 +285,7 @@ fun AppExpandedPlayer( modifier = Modifier .aspectRatio(1f) .clickable { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() when (status) { is DownloadStatus.Completed -> { scope.launch { @@ -375,7 +373,8 @@ fun AppExpandedPlayer( icon = Iconsax.InconsaxClock, label = "Sleep Timer", onClick = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() + playerOptionsViewModel.showSleepTimerDialog() onSleepTimer() }, ) @@ -385,7 +384,7 @@ fun AppExpandedPlayer( icon = Iconsax.IconsaxCd, label = "Go to Album", onClick = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() val albumId = currentTrack?.album?.id if (albumId != null) { 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, maxLines = 1, ) - GhostIconButton(onClick = { showMoreOptionsSheet = true }) { + GhostIconButton(onClick = playerOptionsViewModel::showMoreOptionsSheet) { Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") } } @@ -744,4 +753,4 @@ private fun OptionTile( ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index a6e09477..80916351 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -18,7 +18,6 @@ package dev.krtirtho.spotube.modules.shell import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.shape.CircleShape 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.Icon 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.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.blurEffect 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.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.jam.JamRole 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.IconButton import dev.krtirtho.spotube.core.ui.base.Slider @@ -122,6 +124,7 @@ fun AppLargePlayer( audioPlayer: AudioPlayerInterface = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(), downloadsViewModel: DownloadsViewModel = koinViewModel(), + playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(), savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } @@ -134,11 +137,16 @@ fun AppLargePlayer( .collectAsStateWithLifecycle(initialValue = false) val scope = rememberCoroutineScope() 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 seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + PlayerOptionDialogs(viewModel = playerOptionsViewModel) + LaunchedEffect(playerUiState.progress, isSeeking) { if (!isSeeking) { seekProgress = playerUiState.progress @@ -383,8 +391,45 @@ fun AppLargePlayer( GhostIconButton(onClick = onLyrics) { Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") } - GhostIconButton(onClick = onMoreOptions) { - Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") + Box { + GhostIconButton( + onClick = { + playerOptionsViewModel.showMoreOptionsMenu() + onMoreOptions() + } + ) { + Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") + } + DropdownMenu( + expanded = playerOptionsUiState.isMoreOptionsMenuOpen, + onDismissRequest = playerOptionsViewModel::dismissMoreOptionsMenu, + ) { + DropdownMenuItem( + text = { Text("Go to album") }, + enabled = currentTrack?.album?.id != null, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + currentTrack?.album?.id?.let { albumId -> + navigationCommands.navigateTo(Routes.Album(albumId)) + } + }, + ) + DropdownMenuItem( + text = { Text("Sleep timer") }, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showSleepTimerDialog() + }, + ) + DropdownMenuItem( + text = { Text("Track details") }, + enabled = currentTrack != null, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showTrackDetails() + }, + ) + } } } Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt new file mode 100644 index 00000000..706fb061 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt @@ -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 . + */ + +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" +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt new file mode 100644 index 00000000..8a22546e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -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 . + */ + +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 = 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 = _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 + } +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt new file mode 100644 index 00000000..97f72cc4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -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, + 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)) + } +} diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt index 28f0eeb4..4f2d341e 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt @@ -28,6 +28,7 @@ data class AudioFormat( val qualities: List ) +@Serializable sealed interface AudioQuality { @Serializable @SerialName("lossy") diff --git a/plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt b/plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt new file mode 100644 index 00000000..b676d48f --- /dev/null +++ b/plugin_interfaces/src/commonTest/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt @@ -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>(encoded) + + assertEquals(formats, decoded) + } +}