feat(player-options): add PlayerOptionsViewModel and enhance player UI with options menu

This commit is contained in:
Kingkor Roy Tirtho 2026-09-25 14:53:10 +06:00
parent df9403f2d3
commit 1e6aee142b
16 changed files with 1382 additions and 98 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

@ -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

@ -75,6 +75,7 @@ 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.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 +97,7 @@ val sharedModules = module {
// Shell // Shell
viewModelOf(::AppShellViewModel) viewModelOf(::AppShellViewModel)
viewModelOf(::PlayerOptionsViewModel)
viewModelOf(::PlayerQueueContentViewModel) viewModelOf(::PlayerQueueContentViewModel)
viewModelOf(::AlternativeTrackContentViewModel) viewModelOf(::AlternativeTrackContentViewModel)

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

@ -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
@ -122,6 +124,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 +137,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 +391,46 @@ 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("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) { 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)
}
}