mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(player-options): add PlayerOptionsViewModel and enhance player UI with options menu
This commit is contained in:
parent
df9403f2d3
commit
1e6aee142b
@ -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
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"gradle.autoDetect": "off",
|
||||
"java.gradle.buildServer.enabled": "off"
|
||||
}
|
||||
17
AGENTS.md
17
AGENTS.md
@ -56,4 +56,19 @@
|
||||
- **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release.
|
||||
- **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease.
|
||||
- **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets.
|
||||
- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached.
|
||||
- `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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality,
|
||||
)
|
||||
if (resolvedStream != null) {
|
||||
val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol)
|
||||
val info = StreamInfo(
|
||||
url,
|
||||
resolvedStream.protocol,
|
||||
resolvedStream.codec,
|
||||
resolvedStream.container
|
||||
)
|
||||
cacheStreamInfo(trackId, info)
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
|
||||
return info
|
||||
}
|
||||
}
|
||||
@ -182,45 +183,66 @@ class StreamingUrlRepository(
|
||||
}
|
||||
|
||||
logger.d { "Resolved stream for track $trackId via track lookup" }
|
||||
val resolvedStream = stream.streams.firstOrNull()
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality,
|
||||
)
|
||||
if (resolvedStream != null) {
|
||||
val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol)
|
||||
val info = StreamInfo(
|
||||
url,
|
||||
resolvedStream.protocol,
|
||||
resolvedStream.codec,
|
||||
resolvedStream.container
|
||||
)
|
||||
cacheStreamInfo(trackId, info)
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream)
|
||||
return info
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun getCachedStreamUrl(trackId: String): StreamInfo? {
|
||||
private fun AudioStream.toStreamInfo() = StreamInfo(
|
||||
url = normalizeManifestUrl(url, protocol),
|
||||
protocol = protocol,
|
||||
codec = codec,
|
||||
container = container,
|
||||
)
|
||||
|
||||
private suspend fun getCachedStreamUrl(
|
||||
trackId: String,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
): StreamInfo? {
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
return streamUrlCacheMutex.withLock {
|
||||
val cached = streamUrlCache[trackId] ?: return@withLock null
|
||||
if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) {
|
||||
return@withLock null
|
||||
}
|
||||
if (cached.expiresAtMs <= now) {
|
||||
streamUrlCache.remove(trackId)
|
||||
logger.v { "Cached stream URL expired for track $trackId" }
|
||||
return@withLock null
|
||||
}
|
||||
StreamInfo(cached.url, cached.protocol, cached.container, cached.codec)
|
||||
StreamInfo(cached.url, cached.protocol, cached.codec, cached.container)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cacheStreamInfo(trackId: String, info: StreamInfo) {
|
||||
private suspend fun cacheStreamInfo(
|
||||
trackId: String,
|
||||
info: StreamInfo,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
source: AudioSource.Streamed,
|
||||
selectedStream: AudioStream,
|
||||
) {
|
||||
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS
|
||||
streamUrlCacheMutex.withLock {
|
||||
streamUrlCache[trackId] =
|
||||
CachedStreamUrl(
|
||||
url = info.url,
|
||||
protocol = info.protocol,
|
||||
expiresAtMs = expiresAtMs,
|
||||
container = info.container,
|
||||
codec = info.codec
|
||||
)
|
||||
streamUrlCache[trackId] = CachedStreamUrl(
|
||||
url = info.url,
|
||||
protocol = info.protocol,
|
||||
expiresAtMs = expiresAtMs,
|
||||
container = info.container,
|
||||
codec = info.codec,
|
||||
source = source,
|
||||
selectedStream = selectedStream,
|
||||
preferredFormat = preferredFormat,
|
||||
preferredQuality = preferredQuality,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -232,6 +254,7 @@ class StreamingUrlRepository(
|
||||
logger.v { "Invalidated cached stream URL for track $trackId" }
|
||||
}
|
||||
}
|
||||
// Stream metadata is part of the same URL-cache entry and is removed with it.
|
||||
}
|
||||
|
||||
suspend fun getCachedAlternatives(trackId: String): List<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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -744,4 +753,4 @@ private fun OptionTile(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,8 +391,45 @@ fun AppLargePlayer(
|
||||
GhostIconButton(onClick = onLyrics) {
|
||||
Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics")
|
||||
}
|
||||
GhostIconButton(onClick = onMoreOptions) {
|
||||
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
|
||||
Box {
|
||||
GhostIconButton(
|
||||
onClick = {
|
||||
playerOptionsViewModel.showMoreOptionsMenu()
|
||||
onMoreOptions()
|
||||
}
|
||||
) {
|
||||
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = playerOptionsUiState.isMoreOptionsMenuOpen,
|
||||
onDismissRequest = playerOptionsViewModel::dismissMoreOptionsMenu,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Go to album") },
|
||||
enabled = currentTrack?.album?.id != null,
|
||||
onClick = {
|
||||
playerOptionsViewModel.dismissMoreOptionsMenu()
|
||||
currentTrack?.album?.id?.let { albumId ->
|
||||
navigationCommands.navigateTo(Routes.Album(albumId))
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sleep timer") },
|
||||
onClick = {
|
||||
playerOptionsViewModel.dismissMoreOptionsMenu()
|
||||
playerOptionsViewModel.showSleepTimerDialog()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Track details") },
|
||||
enabled = currentTrack != null,
|
||||
onClick = {
|
||||
playerOptionsViewModel.dismissMoreOptionsMenu()
|
||||
playerOptionsViewModel.showTrackDetails()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,7 @@ data class AudioFormat(
|
||||
val qualities: List<AudioQuality>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed interface AudioQuality {
|
||||
@Serializable
|
||||
@SerialName("lossy")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user