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": {
"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"
]
}
}
}

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.
- **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.
## 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
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

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

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.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)

View File

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

View File

@ -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<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(
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()
if (resolvedStream != null) {
val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol)
val info = StreamInfo(
url,
resolvedStream.protocol,
resolvedStream.codec,
resolvedStream.container
val resolvedStream = selectPreferredAudioStream(
stream.streams,
preferredFormat,
preferredQuality,
)
cacheStreamInfo(trackId, info)
if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
return info
}
}
@ -182,44 +183,65 @@ class StreamingUrlRepository(
}
logger.d { "Resolved stream for track $trackId via track lookup" }
val resolvedStream = stream.streams.firstOrNull()
if (resolvedStream != null) {
val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol)
val info = StreamInfo(
url,
resolvedStream.protocol,
resolvedStream.codec,
resolvedStream.container
val resolvedStream = selectPreferredAudioStream(
stream.streams,
preferredFormat,
preferredQuality,
)
cacheStreamInfo(trackId, info)
if (resolvedStream != null) {
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(
streamUrlCache[trackId] = CachedStreamUrl(
url = info.url,
protocol = info.protocol,
expiresAtMs = expiresAtMs,
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" }
}
}
// Stream metadata is part of the same URL-cache entry and is removed with it.
}
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) {
alternativesCacheMutex.withLock {
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 {
return "${codec.uppercase()} in ${container.uppercase()} • ${preferredQuality().displayLabel()}"
return "${codec.uppercase()} in ${container.uppercase()}"
}
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.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<SavedTracksViewModel>(
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")
}
}

View File

@ -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<SavedTracksViewModel>(
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,9 +391,46 @@ fun AppLargePlayer(
GhostIconButton(onClick = onLyrics) {
Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics")
}
GhostIconButton(onClick = onMoreOptions) {
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) {
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>
)
@Serializable
sealed interface AudioQuality {
@Serializable
@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)
}
}